diff --git a/app/components/public-home/ReaderLayout.vue b/app/components/public-home/ReaderLayout.vue new file mode 100644 index 0000000..7162548 --- /dev/null +++ b/app/components/public-home/ReaderLayout.vue @@ -0,0 +1,352 @@ + + + diff --git a/app/components/public-home/ShowcaseLayout.vue b/app/components/public-home/ShowcaseLayout.vue new file mode 100644 index 0000000..821dabb --- /dev/null +++ b/app/components/public-home/ShowcaseLayout.vue @@ -0,0 +1,260 @@ + + + diff --git a/app/pages/@[publicSlug]/index.vue b/app/pages/@[publicSlug]/index.vue index 45c9deb..0af51ac 100644 --- a/app/pages/@[publicSlug]/index.vue +++ b/app/pages/@[publicSlug]/index.vue @@ -3,7 +3,9 @@ import { unwrapApiBody, type ApiResponse } from '../../utils/http/factory' import { extractFrontMatterDesc, stripFrontMatter } from '../../utils/markdown-front-matter' import { buildPublicCanonicalUrl } from '../../utils/public-canonical-url' import { safeExternalHref } from '../../utils/safe-external-href' -import { formatOccurredOnDisplay, formatPublishedDateOnly, occurredOnToIsoAttr } from '../../utils/timeline-datetime' +import { usePublicProfileLayoutMode } from '../../composables/usePublicHomeLayout' +import ShowcaseLayout from '../../components/public-home/ShowcaseLayout.vue' +import ReaderLayout from '../../components/public-home/ReaderLayout.vue' definePageMeta({ layout: 'public', @@ -12,20 +14,17 @@ definePageMeta({ const route = useRoute() const runtimeConfig = useRuntimeConfig() const slug = computed(() => route.params.publicSlug as string) +const { mode } = usePublicProfileLayoutMode() type PublicPostListItem = { title?: string | null excerpt?: string | null slug?: string | null publishedAt?: Date | string | null - coverUrl?: string | null } type PublicTimelineItem = { - id?: number title?: string | null - occurredOn?: Date | string | null - linkUrl?: string | null bodyMarkdown?: string | null } @@ -67,62 +66,11 @@ function normalizeModule(primary?: ModulePayload, fallback?: ModulePayload } } -function rssPublicHref(it: PublicRssListItem): string | undefined { - const u = it.canonicalUrl ?? it.canonical_url - return safeExternalHref(u) -} - -function rssPublicTitle(it: PublicRssListItem): string { - const t = it.title - return typeof t === 'string' && t.trim().length ? t : '未命名' -} - -function postPublicTitle(it: PublicPostListItem): string { - const t = it.title - return typeof t === 'string' && t.trim().length ? t : '未命名文章' -} - -function postPublicExcerpt(it: PublicPostListItem): string { - const e = it.excerpt - return typeof e === 'string' ? e : '' -} - -function postPublicSlug(it: PublicPostListItem): string { - const s = it.slug - return typeof s === 'string' ? s : '' -} - -function timelinePublicTitle(it: PublicTimelineItem): string { - const t = it.title - return typeof t === 'string' && t.trim().length ? t : '未命名动态' -} - -function timelinePublicBody(it: PublicTimelineItem): string { - const b = it.bodyMarkdown - return typeof b === 'string' ? b : '' -} - -function timelineItemKey(e: PublicTimelineItem, i: number): string | number { - return e.id ?? i -} - function socialHref(link: { url?: string; href?: string }): string | undefined { const value = link.url ?? link.href return safeExternalHref(value, { allowMailto: true }) } -function rssHostname(href: string | undefined): string { - if (!href) { - return '' - } - try { - return new URL(href).hostname - } - catch { - return href - } -} - const { data, pending, error } = await useAsyncData( () => `public-profile-${slug.value}`, async () => { @@ -162,21 +110,16 @@ const bioPreviewText = computed(() => { }) const hasBioPreview = computed(() => bioPreviewText.value.length > 0) +const isDetailedMode = computed(() => mode.value === 'detailed') const socialLinks = computed(() => (data.value?.links ?? []) .map(link => ({ ...link, safeHref: socialHref(link) })) - .filter(link => typeof link.safeHref === 'string' && link.safeHref.length > 0), -) - -const readingPreviewItems = computed(() => - readingModule.value.items.slice(0, 2).map(item => ({ - item, - href: rssPublicHref(item), - })), -) -const hasModuleContent = computed(() => - postsModule.value.total > 0 || timelineModule.value.total > 0 || readingModule.value.total > 0, + .filter(link => typeof link.safeHref === 'string' && link.safeHref.length > 0) as Array<{ + label: string + icon?: string + safeHref: string + }>, ) const canonicalUrl = computed(() => @@ -209,168 +152,30 @@ usePageTitle(() => { -
-
-
-
- -
-
-

- {{ data.user.nickname || data.user.publicSlug || slug }} -

-

- @{{ data.user.publicSlug }} -

-
- -
-
- -
- -

- {{ bioPreviewText }} -

-

- 查看完整介绍 -

-
-
- - - -
+ +
diff --git a/docs/superpowers/plans/2026-04-24-public-home-subpages-implementation-plan.md b/docs/superpowers/plans/2026-04-24-public-home-subpages-implementation-plan.md new file mode 100644 index 0000000..68e4d9f --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-public-home-subpages-implementation-plan.md @@ -0,0 +1,317 @@ +# Public Home Subpages 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:** 将 `@slug` 首页收敛为“导航中枢”,统一公开口径,并把完整内容承载下沉到 `posts/timeline/reading` 子页面。 + +**Architecture:** 在服务端新增“中枢聚合查询”层,确保首页卡片计数与子页分页共用同一 public 过滤口径;在前端将 `app/pages/@[publicSlug]/index.vue` 重构为统一模块卡片骨架(标题、描述、预览、查看全部)。同时补齐公开 API 口径测试与页面标题/canonical 校验,避免 unlisted/private 泄漏与 SEO 冲突。 + +**Tech Stack:** Nuxt 4、Vue 3、Nitro API、Drizzle ORM、Bun Test + +--- + +## File Structure + +**Create** +- `server/service/public-hub/index.ts`:公开中枢聚合服务(模块计数 + 预览) +- `server/service/public-hub/index.test.ts`:中枢聚合口径测试(public/unlisted/private) +- `app/components/public-hub/HubModuleCard.vue`:统一模块卡片组件 +- `docs/superpowers/specs/2026-04-24-public-home-subpages-design.md`(仅在实现偏差时补充变更说明) + +**Modify** +- `server/api/public/profile/[publicSlug].get.ts`:改为调用中枢聚合服务,统一返回结构 +- `server/api/public/profile/[publicSlug]/posts/index.get.ts`:校验与中枢相同 public 过滤逻辑 +- `server/api/public/profile/[publicSlug]/timeline/index.get.ts`:校验与中枢相同 public 过滤逻辑 +- `server/api/public/profile/[publicSlug]/reading/index.get.ts`:校验与中枢相同 public 过滤逻辑 +- `app/pages/@[publicSlug]/index.vue`:改造成“导航中枢”布局,限制每模块最多 2 条预览 +- `app/pages/@[publicSlug]/posts/index.vue`:补充 canonical 与空态文案一致性 +- `app/pages/@[publicSlug]/timeline/index.vue`:补充 canonical 与空态文案一致性 +- `app/pages/@[publicSlug]/reading/index.vue`:补充 canonical 与空态文案一致性 + +**Test** +- `server/service/public-hub/index.test.ts` +- `server/utils/site-public.test.ts`(扩展 canonical 相关断言,如涉及公用函数) + +--- + +### Task 1: 建立公开中枢聚合服务(统一口径) + +**Files:** +- Create: `server/service/public-hub/index.ts` +- Test: `server/service/public-hub/index.test.ts` + +- [ ] **Step 1: 写失败测试(只允许 public 进入中枢)** + +```ts +import { describe, expect, test } from "bun:test"; +import { buildPublicHubPayload } from "./index"; + +describe("buildPublicHubPayload", () => { + test("only exposes public items in counts and previews", async () => { + const payload = await buildPublicHubPayload("alice"); + expect(payload.modules.posts.total).toBeGreaterThanOrEqual(0); + expect(payload.modules.posts.preview.every((x) => x.visibility === "public")).toBe(true); + expect(payload.modules.timeline.preview.every((x) => x.visibility === "public")).toBe(true); + expect(payload.modules.reading.preview.every((x) => x.visibility === "public")).toBe(true); + }); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `bun test server/service/public-hub/index.test.ts` +Expected: FAIL(`buildPublicHubPayload` 未定义或断言失败) + +- [ ] **Step 3: 写最小实现(聚合 + preview limit)** + +```ts +export async function buildPublicHubPayload(publicSlug: string) { + const posts = await getPublicPostsPreviewBySlug(publicSlug, { limit: 2 }); + const timeline = await getPublicTimelinePreviewBySlug(publicSlug, { limit: 2 }); + const reading = await getPublicRssPreviewBySlug(publicSlug, { limit: 2 }); + return { + modules: { + posts: { total: posts.total, preview: posts.items }, + timeline: { total: timeline.total, preview: timeline.items }, + reading: { total: reading.total, preview: reading.items }, + }, + }; +} +``` + +- [ ] **Step 4: 再跑测试确认通过** + +Run: `bun test server/service/public-hub/index.test.ts` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add server/service/public-hub/index.ts server/service/public-hub/index.test.ts +git commit -m "feat(public-hub): add unified public aggregation service" +``` + +--- + +### Task 2: 接入公开主页 API,统一首页返回结构 + +**Files:** +- Modify: `server/api/public/profile/[publicSlug].get.ts` +- Test: `server/service/public-hub/index.test.ts` + +- [ ] **Step 1: 写失败测试(首页口径与中枢一致)** + +```ts +test("profile endpoint uses unified module totals", async () => { + const payload = await buildPublicHubPayload("alice"); + expect(payload.modules.posts.total).toEqual(payload.modules.posts.preview.length || payload.modules.posts.total); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `bun test server/service/public-hub/index.test.ts -t "profile endpoint uses unified module totals"` +Expected: FAIL(尚未在 API 层接入) + +- [ ] **Step 3: 在 API 中改为调用中枢服务** + +```ts +import { buildPublicHubPayload } from "#server/service/public-hub"; + +const hub = await buildPublicHubPayload(publicSlug); +return R.success({ + user: ..., + bio: ..., + links, + modules: hub.modules, +}); +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `bun test server/service/public-hub/index.test.ts` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add server/api/public/profile/[publicSlug].get.ts server/service/public-hub/index.test.ts +git commit -m "refactor(public-api): use unified hub payload for profile home" +``` + +--- + +### Task 3: 抽象首页模块卡片组件(统一结构与文案) + +**Files:** +- Create: `app/components/public-hub/HubModuleCard.vue` +- Modify: `app/pages/@[publicSlug]/index.vue` + +- [ ] **Step 1: 写失败测试(组件渲染规范)** + +```ts +test("hub card renders title, total, preview and CTA", () => { + // mount PublicHubModuleCard with minimal props + // assert contains "查看全部文章" and preview length <= 2 +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `bun test app/components/public-hub` +Expected: FAIL(组件不存在) + +- [ ] **Step 3: 实现统一卡片组件并替换首页模块区** + +```vue + +``` + +- [ ] **Step 4: 手工验证页面结构** + +Run: `bun run dev` +Expected: `@slug` 首页显示三张模块卡,顺序为文章→时光机→阅读,且每卡最多 2 条预览。 + +- [ ] **Step 5: 提交** + +```bash +git add app/components/public-hub/HubModuleCard.vue app/pages/@[publicSlug]/index.vue +git commit -m "feat(public-home): switch to hub-style module cards" +``` + +--- + +### Task 4: 子页面与首页口径一致性校验(计数、空态、排序) + +**Files:** +- Modify: `server/api/public/profile/[publicSlug]/posts/index.get.ts` +- Modify: `server/api/public/profile/[publicSlug]/timeline/index.get.ts` +- Modify: `server/api/public/profile/[publicSlug]/reading/index.get.ts` + +- [ ] **Step 1: 写失败测试(子页 total 与中枢同口径)** + +```ts +test("subpage totals match hub totals for same slug", async () => { + const hub = await buildPublicHubPayload("alice"); + const posts = await getPublicPostsBySlug("alice", { page: 1, pageSize: 10 }); + expect(posts.total).toBe(hub.modules.posts.total); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `bun test server/service/public-hub/index.test.ts -t "subpage totals match hub totals"` +Expected: FAIL(若当前查询条件不一致) + +- [ ] **Step 3: 对齐各子页 API 的 public 过滤和排序** + +```ts +where(and(eq(ownerId, userId), eq(visibility, "public"))) +orderBy(desc(publishedAt)) +``` + +- [ ] **Step 4: 再跑测试确认通过** + +Run: `bun test server/service/public-hub/index.test.ts` +Expected: PASS + +- [ ] **Step 5: 提交** + +```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 server/service/public-hub/index.test.ts +git commit -m "fix(public-content): align subpage totals and ordering with hub" +``` + +--- + +### Task 5: SEO 与 canonical 一致性 + +**Files:** +- Modify: `app/pages/@[publicSlug]/index.vue` +- Modify: `app/pages/@[publicSlug]/posts/index.vue` +- Modify: `app/pages/@[publicSlug]/timeline/index.vue` +- Modify: `app/pages/@[publicSlug]/reading/index.vue` +- Test: `server/utils/site-public.test.ts`(如复用站点 URL 工具) + +- [ ] **Step 1: 写失败测试(canonical 生成)** + +```ts +test("site public url helper builds stable origin", () => { + process.env.NUXT_PUBLIC_SITE_URL = "https://example.com"; + expect(getSitePublicUrlFromEnv()).toBe("https://example.com"); +}); +``` + +- [ ] **Step 2: 运行测试确认失败(若需扩展 helper)** + +Run: `bun test server/utils/site-public.test.ts` +Expected: FAIL(若新增 helper 尚未实现) + +- [ ] **Step 3: 在公开页面统一设置 canonical 与标题语义** + +```ts +useSeoMeta({ title: `${slug.value} 的主页` }); +useHead({ link: [{ rel: "canonical", href: canonicalUrl.value }] }); +``` + +- [ ] **Step 4: 回归验证** + +Run: `bun test server/utils/site-public.test.ts && bun run dev` +Expected: 测试通过;页面 head 中 canonical 正确,unlisted 页面不在公开导航入口。 + +- [ ] **Step 5: 提交** + +```bash +git add app/pages/@[publicSlug]/index.vue app/pages/@[publicSlug]/posts/index.vue app/pages/@[publicSlug]/timeline/index.vue app/pages/@[publicSlug]/reading/index.vue server/utils/site-public.test.ts +git commit -m "feat(public-seo): unify canonical metadata for public hub pages" +``` + +--- + +### Task 6: 最终验收与文档同步 + +**Files:** +- Modify: `docs/superpowers/specs/2026-04-24-public-home-subpages-design.md`(如有实现差异) +- Modify: `docs/superpowers/plans/2026-04-24-public-home-subpages-implementation-plan.md`(勾选执行项) + +- [ ] **Step 1: 执行最小验收清单** + +Run: `bun test server/service/public-hub/index.test.ts && bun test server/utils/site-public.test.ts` +Expected: 全部 PASS + +- [ ] **Step 2: 手工回归公开访问路径** + +Run: `bun run dev` +Expected: +- `@slug` 首页仅显示公开预览 +- `unlisted` 不出现在首页与公开子页列表 +- 子页分页总数与首页卡片计数一致 + +- [ ] **Step 3: 同步文档(如有偏差)** + +```md +## 实现偏差记录 +- [日期] 将阅读模块 CTA 文案从“查看阅读清单”改为“查看全部阅读条目”,以匹配现有信息架构术语。 +``` + +- [ ] **Step 4: 最终提交** + +```bash +git add docs/superpowers/specs/2026-04-24-public-home-subpages-design.md docs/superpowers/plans/2026-04-24-public-home-subpages-implementation-plan.md +git commit -m "docs(plan): finalize implementation checklist for public hub mode" +``` + +--- + +## Self-Review + +1. **Spec coverage:** 已覆盖首页中枢化、模块卡片规范、可见性口径一致、SEO/canonical、验收测试与扩展约束。 +2. **Placeholder scan:** 计划内无 TBD/TODO/“后续补充”占位项。 +3. **Type consistency:** 统一使用 `public` 口径、`modules.posts|timeline|reading` 命名,避免前后不一致。 diff --git a/docs/superpowers/specs/2026-04-24-public-home-subpages-design.md b/docs/superpowers/specs/2026-04-24-public-home-subpages-design.md new file mode 100644 index 0000000..e1d7239 --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-public-home-subpages-design.md @@ -0,0 +1,147 @@ +# 个人主页子页面化能力评审设计(导航中枢模式) + +## 1. 背景与目标 + +当前项目已经具备公开主页与多个公开子页面能力,用户可以从个人主页进入文章、时光机、阅读(RSS)等模块。 +本次评审目标是将 `@slug` 主页明确定位为“导航中枢”,避免主页继续演化为内容聚合长页。 + +目标如下: + +- `@slug` 仅承担入口分发职责,不承担长内容阅读。 +- 文章、时光机、阅读均为一等子页面,具备独立列表与分页能力。 +- 公开、仅链接、私密三种可见性在公开侧严格边界清晰。 +- 新增模块可按统一规范接入,保持长期可扩展性。 + +## 2. 方案结论 + +本次评审采用 **方案 1:导航中枢**。 + +- 主页只展示身份信息、模块入口卡片、每模块 1-2 条预览和“查看全部”。 +- 子页面承载完整列表、详情跳转与后续模块特有能力。 +- 首页不引入模块专属复杂交互(例如筛选、排序、阅读模式)。 + +## 3. 信息架构 + +### 3.1 页面定位 + +- `@slug`:个人站首页(目录页 / 导航中枢) +- `@slug/posts`:文章列表页 +- `@slug/timeline`:时光机列表页 +- `@slug/reading`:阅读(RSS)列表页 + +### 3.2 入口层级 + +- 一级入口:文章 / 时光机 / 阅读模块卡片 +- 二级入口:卡片内最近 1-2 条预览 +- 三级入口:统一“查看全部”按钮进入子页 + +### 3.3 内容边界 + +- 首页每模块最多展示 2 条预览(建议可配置,默认 2)。 +- 首页不展示完整正文,不承载深度浏览流程。 +- 详情阅读、历史翻页、重度交互全部下沉到子页面。 + +### 3.4 可扩展约束 + +新增公开模块必须满足: + +- 提供 `@slug/` 独立子页 +- 在 `@slug` 提供模块卡片入口 +- 提供统一“查看全部”跳转 + +## 4. 入口组件规范 + +### 4.1 卡片结构(统一骨架) + +每个模块卡片应包含: + +- 标题行:模块名 + 公开总数(如“文章 · 24”) +- 描述行:模块价值说明(10-18 字) +- 预览区:最近 1-2 条(标题 + 日期/来源) +- 操作区:主按钮“查看全部”,次按钮按需配置 + +### 4.2 文案规范 + +- 文章:查看全部文章 +- 时光机:查看全部动态 +- 阅读:查看阅读清单 + +文案采用“动作 + 对象”,避免“更多”等弱语义措辞。 + +### 4.3 状态规范 + +- 正常态:展示预览条目 +- 空态:显示“暂无公开内容”并给出轻提示 +- 异常态:显示“暂时不可用”,不暴露技术细节 + +### 4.4 一致性规则 + +- 固定顺序:文章 → 时光机 → 阅读 +- 预览排序:统一按时间倒序 +- 交互一致:卡片整体可点、预览可点、CTA 始终可见 + +## 5. 可见性与数据口径 + +### 5.1 公开侧展示规则 + +- `@slug` 主页仅展示 `public` 内容。 +- `@slug/posts`、`@slug/timeline`、`@slug/reading` 公开访问仅返回 `public` 内容。 +- 首页卡片计数与子页总数均以 `public` 口径计算。 + +### 5.2 仅链接(unlisted)规则 + +- 不进入 `@slug` 首页预览与计数。 +- 不进入公开子页列表。 +- 仅允许通过 token 链接访问(例如 `/p/:publicSlug/t/:shareToken`)。 + +### 5.3 私密(private)规则 + +- 仅后台站主可见。 +- 不进入任何公开 API 返回。 +- 不进入 sitemap 与公开聚合统计。 + +### 5.4 口径一致性要求 + +同一模块在以下位置的“数量与内容范围”必须一致: + +- 首页卡片总数 +- 子页分页总数 +- 公开 SEO/结构化数据中的条目数 + +## 6. 风险评审结论 + +### 6.1 高风险 + +1. 统计口径不一致导致用户信任受损 +2. `unlisted` 意外进入公开列表导致泄漏 + +### 6.2 中风险 + +1. 主页功能膨胀回归为内容聚合页 +2. 主页与子页 SEO 信号冲突 +3. 新模块接入方式不统一导致维护复杂度上升 + +### 6.3 低风险 + +1. 首页聚合请求过重影响首屏性能 + +## 7. 验收标准(最小集) + +1. 访客访问 `@slug` 仅看到公开模块与公开预览。 +2. `unlisted` 内容无法通过首页与公开子页列表发现,仅 token 可达。 +3. 子页分页总数与首页模块计数口径一致。 +4. 模块空态与异常态表现统一,不影响其他模块。 +5. 主页与子页 canonical 各自独立,`unlisted` 不进入 sitemap。 +6. 新模块可按“卡片入口 + 独立子页 + 查看全部”模板接入。 + +## 8. 实施边界(本次仅设计评审) + +本文件仅确认信息架构与评审结论,不直接包含代码改造。 +下一步应进入实现规划阶段,将本设计拆分为可执行任务(路由、数据聚合、组件抽象、SEO 与测试)。 + +## 9. 规格自检(已完成) + +- 已检查:无 TBD/TODO 占位符。 +- 已检查:信息架构、可见性规则、验收标准之间无明显冲突。 +- 已检查:范围聚焦于“导航中枢化评审”,未引入无关重构。 +- 已检查:关键术语(公开/仅链接/私密)定义明确,口径一致。 diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index 7a72564..2870691 100644 Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ