Browse Source
Add ReaderLayout and ShowcaseLayout components to enhance the public profile page, allowing for modular display of posts, timeline, and reading sections. This change establishes a unified navigation hub, improving user experience with structured content previews and consistent API handling. Made-with: Cursormain
6 changed files with 1110 additions and 229 deletions
@ -0,0 +1,352 @@ |
|||
<script setup lang="ts"> |
|||
import { unwrapApiBody, type ApiResponse } from '../../utils/http/factory' |
|||
import { safeExternalHref } from '../../utils/safe-external-href' |
|||
|
|||
type Section = 'posts' | 'timeline' | 'reading' |
|||
|
|||
type SocialLink = { |
|||
label: string |
|||
icon?: string |
|||
safeHref: string |
|||
} |
|||
|
|||
type PostItem = { |
|||
title?: string | null |
|||
excerpt?: string | null |
|||
slug?: string | null |
|||
publishedAt?: Date | string | null |
|||
} |
|||
|
|||
type TimelineItem = { |
|||
id?: number |
|||
title?: string | null |
|||
occurredOn?: Date | string | null |
|||
bodyMarkdown?: string | null |
|||
linkUrl?: string | null |
|||
} |
|||
|
|||
type ReadingItem = { |
|||
title?: string | null |
|||
canonicalUrl?: string | null |
|||
canonical_url?: string | null |
|||
} |
|||
|
|||
type ListPayload<T> = { |
|||
items: T[] |
|||
total: number |
|||
page: number |
|||
pageSize: number |
|||
} |
|||
|
|||
const props = defineProps<{ |
|||
slug: string |
|||
displayName: string |
|||
publicSlug: string | null |
|||
avatar: string | null |
|||
bioPreviewText: string |
|||
hasBioPreview: boolean |
|||
socialLinks: SocialLink[] |
|||
}>() |
|||
|
|||
const activeSection = ref<Section>('posts') |
|||
const postsPage = ref(1) |
|||
const timelinePage = ref(1) |
|||
const readingPage = ref(1) |
|||
|
|||
const { data: postsData, pending: postsPending } = await useAsyncData( |
|||
() => `reader-posts-${props.slug}-${postsPage.value}`, |
|||
async () => { |
|||
const base = `/api/public/profile/${encodeURIComponent(props.slug)}/posts` |
|||
const url = postsPage.value > 1 ? `${base}?page=${postsPage.value}` : base |
|||
const res = await $fetch<ApiResponse<ListPayload<PostItem>>>(url) |
|||
return unwrapApiBody(res) |
|||
}, |
|||
{ watch: [() => props.slug, postsPage] }, |
|||
) |
|||
|
|||
const { data: timelineData, pending: timelinePending } = await useAsyncData( |
|||
() => `reader-timeline-${props.slug}-${timelinePage.value}`, |
|||
async () => { |
|||
const base = `/api/public/profile/${encodeURIComponent(props.slug)}/timeline` |
|||
const url = timelinePage.value > 1 ? `${base}?page=${timelinePage.value}` : base |
|||
const res = await $fetch<ApiResponse<ListPayload<TimelineItem>>>(url) |
|||
return unwrapApiBody(res) |
|||
}, |
|||
{ watch: [() => props.slug, timelinePage] }, |
|||
) |
|||
|
|||
const { data: readingData, pending: readingPending } = await useAsyncData( |
|||
() => `reader-reading-${props.slug}-${readingPage.value}`, |
|||
async () => { |
|||
const base = `/api/public/profile/${encodeURIComponent(props.slug)}/reading` |
|||
const url = readingPage.value > 1 ? `${base}?page=${readingPage.value}` : base |
|||
const res = await $fetch<ApiResponse<ListPayload<ReadingItem>>>(url) |
|||
return unwrapApiBody(res) |
|||
}, |
|||
{ watch: [() => props.slug, readingPage] }, |
|||
) |
|||
|
|||
const postsTotal = computed(() => postsData.value?.total ?? 0) |
|||
const timelineTotal = computed(() => timelineData.value?.total ?? 0) |
|||
const readingTotal = computed(() => readingData.value?.total ?? 0) |
|||
|
|||
watch(() => props.slug, () => { |
|||
postsPage.value = 1 |
|||
timelinePage.value = 1 |
|||
readingPage.value = 1 |
|||
activeSection.value = 'posts' |
|||
}) |
|||
|
|||
function postTitle(it: PostItem): string { |
|||
const t = it.title |
|||
return typeof t === 'string' && t.trim().length ? t : '未命名文章' |
|||
} |
|||
|
|||
function postExcerpt(it: PostItem): string { |
|||
return typeof it.excerpt === 'string' ? it.excerpt : '' |
|||
} |
|||
|
|||
function postSlug(it: PostItem): string { |
|||
return typeof it.slug === 'string' ? it.slug : '' |
|||
} |
|||
|
|||
function timelineTitle(it: TimelineItem): string { |
|||
const t = it.title |
|||
return typeof t === 'string' && t.trim().length ? t : '未命名动态' |
|||
} |
|||
|
|||
function timelineBody(it: TimelineItem): string { |
|||
return typeof it.bodyMarkdown === 'string' ? it.bodyMarkdown : '' |
|||
} |
|||
|
|||
function timelineKey(it: TimelineItem, index: number): string | number { |
|||
return it.id ?? index |
|||
} |
|||
|
|||
function timelineHref(it: TimelineItem): string | undefined { |
|||
return safeExternalHref(it.linkUrl) |
|||
} |
|||
|
|||
function readingTitle(it: ReadingItem): string { |
|||
const t = it.title |
|||
return typeof t === 'string' && t.trim().length ? t : '未命名' |
|||
} |
|||
|
|||
function readingHref(it: ReadingItem): string | undefined { |
|||
return safeExternalHref(it.canonicalUrl ?? it.canonical_url) |
|||
} |
|||
|
|||
function readingHost(href: string | undefined): string { |
|||
if (!href) { |
|||
return '' |
|||
} |
|||
try { |
|||
return new URL(href).hostname |
|||
} |
|||
catch { |
|||
return href |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="grid gap-6 lg:grid-cols-[16rem_minmax(0,1fr)] xl:gap-8"> |
|||
<aside class="space-y-4 lg:sticky lg:top-20 lg:h-fit"> |
|||
<section class="rounded-2xl border border-default/80 bg-elevated/20 px-4 py-5"> |
|||
<div class="flex items-center gap-3"> |
|||
<img |
|||
v-if="avatar" |
|||
:src="avatar" |
|||
alt="" |
|||
class="h-14 w-14 rounded-full border border-default object-cover" |
|||
> |
|||
<div> |
|||
<p class="font-semibold text-highlighted"> |
|||
{{ displayName }} |
|||
</p> |
|||
<p v-if="publicSlug" class="text-xs text-muted"> |
|||
@{{ publicSlug }} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
<NuxtLink |
|||
v-if="hasBioPreview" |
|||
:to="`/@${slug}/about`" |
|||
class="mt-3 block rounded-lg border border-default/70 bg-default/20 px-3 py-2 text-xs text-muted hover:bg-elevated/40" |
|||
> |
|||
{{ bioPreviewText }} |
|||
</NuxtLink> |
|||
<ul v-if="socialLinks.length" class="mt-3 flex flex-wrap gap-2"> |
|||
<li v-for="(l, i) in socialLinks" :key="i"> |
|||
<a |
|||
:href="l.safeHref" |
|||
target="_blank" |
|||
rel="noopener noreferrer" |
|||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 text-xs text-muted hover:text-default" |
|||
> |
|||
<UIcon :name="socialLinkIconName({ ...l, url: l.safeHref })" class="size-3.5" /> |
|||
{{ l.label }} |
|||
</a> |
|||
</li> |
|||
</ul> |
|||
</section> |
|||
|
|||
<nav class="space-y-1 rounded-2xl border border-default/80 bg-elevated/20 p-2" aria-label="阅读区块切换"> |
|||
<button |
|||
type="button" |
|||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors" |
|||
:class="activeSection === 'posts' ? 'bg-primary/15 text-highlighted' : 'text-muted hover:bg-elevated/50'" |
|||
@click="activeSection = 'posts'" |
|||
> |
|||
<span>文章</span> |
|||
<span class="text-xs tabular-nums">{{ postsTotal }}</span> |
|||
</button> |
|||
<button |
|||
type="button" |
|||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors" |
|||
:class="activeSection === 'timeline' ? 'bg-primary/15 text-highlighted' : 'text-muted hover:bg-elevated/50'" |
|||
@click="activeSection = 'timeline'" |
|||
> |
|||
<span>时光机</span> |
|||
<span class="text-xs tabular-nums">{{ timelineTotal }}</span> |
|||
</button> |
|||
<button |
|||
type="button" |
|||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors" |
|||
:class="activeSection === 'reading' ? 'bg-primary/15 text-highlighted' : 'text-muted hover:bg-elevated/50'" |
|||
@click="activeSection = 'reading'" |
|||
> |
|||
<span>阅读</span> |
|||
<span class="text-xs tabular-nums">{{ readingTotal }}</span> |
|||
</button> |
|||
</nav> |
|||
</aside> |
|||
|
|||
<section class="min-w-0"> |
|||
<div v-if="activeSection === 'posts'" class="space-y-4"> |
|||
<h2 class="text-lg font-semibold text-highlighted"> |
|||
文章 |
|||
</h2> |
|||
<div v-if="postsPending" class="text-sm text-muted"> |
|||
加载中… |
|||
</div> |
|||
<UEmpty v-else-if="!postsData?.total" title="暂无公开文章" /> |
|||
<template v-else> |
|||
<NuxtLink |
|||
v-for="(p, i) in postsData.items" |
|||
:key="`${postSlug(p)}-${i}`" |
|||
:to="`/@${slug}/posts/${encodeURIComponent(postSlug(p))}`" |
|||
class="block rounded-xl border border-default/80 bg-elevated/20 px-4 py-4" |
|||
> |
|||
<div class="font-semibold text-highlighted"> |
|||
{{ postTitle(p) }} |
|||
</div> |
|||
<p v-if="postExcerpt(p)" class="mt-2 text-sm text-muted line-clamp-3"> |
|||
{{ postExcerpt(p) }} |
|||
</p> |
|||
</NuxtLink> |
|||
<div v-if="postsData.total > 10" class="flex justify-end"> |
|||
<UPagination |
|||
:page="postsPage" |
|||
:total="postsData.total" |
|||
:items-per-page="10" |
|||
size="sm" |
|||
@update:page="postsPage = $event" |
|||
/> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
|
|||
<div v-else-if="activeSection === 'timeline'" class="space-y-4"> |
|||
<h2 class="text-lg font-semibold text-highlighted"> |
|||
时光机 |
|||
</h2> |
|||
<div v-if="timelinePending" class="text-sm text-muted"> |
|||
加载中… |
|||
</div> |
|||
<UEmpty v-else-if="!timelineData?.total" title="暂无公开动态" /> |
|||
<template v-else> |
|||
<article |
|||
v-for="(e, i) in timelineData.items" |
|||
:key="timelineKey(e, i)" |
|||
class="rounded-xl border border-default/80 bg-elevated/20 px-4 py-4" |
|||
> |
|||
<div class="flex items-start justify-between gap-3"> |
|||
<h3 class="font-semibold text-highlighted"> |
|||
{{ timelineTitle(e) }} |
|||
</h3> |
|||
<time |
|||
v-if="e.occurredOn" |
|||
class="text-xs tabular-nums text-muted" |
|||
:datetime="occurredOnToIsoAttr(e.occurredOn)" |
|||
>{{ formatOccurredOnDisplay(e.occurredOn) }}</time> |
|||
</div> |
|||
<p v-if="timelineBody(e)" class="mt-2 whitespace-pre-wrap text-sm text-muted"> |
|||
{{ timelineBody(e) }} |
|||
</p> |
|||
<a |
|||
v-if="timelineHref(e)" |
|||
:href="timelineHref(e)" |
|||
target="_blank" |
|||
rel="noopener noreferrer" |
|||
class="mt-2 inline-flex text-sm text-primary hover:underline" |
|||
> |
|||
打开链接 |
|||
</a> |
|||
</article> |
|||
<div v-if="timelineData.total > 10" class="flex justify-end"> |
|||
<UPagination |
|||
:page="timelinePage" |
|||
:total="timelineData.total" |
|||
:items-per-page="10" |
|||
size="sm" |
|||
@update:page="timelinePage = $event" |
|||
/> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
|
|||
<div v-else class="space-y-4"> |
|||
<h2 class="text-lg font-semibold text-highlighted"> |
|||
阅读 |
|||
</h2> |
|||
<div v-if="readingPending" class="text-sm text-muted"> |
|||
加载中… |
|||
</div> |
|||
<UEmpty v-else-if="!readingData?.total" title="暂无公开阅读条目" /> |
|||
<template v-else> |
|||
<template v-for="(it, i) in readingData.items" :key="i"> |
|||
<a |
|||
v-if="readingHref(it)" |
|||
:href="readingHref(it)" |
|||
target="_blank" |
|||
rel="noopener noreferrer" |
|||
class="block rounded-xl border border-default/80 bg-elevated/20 px-4 py-4" |
|||
> |
|||
<div class="font-semibold text-highlighted"> |
|||
{{ readingTitle(it) }} |
|||
</div> |
|||
<div class="mt-2 text-sm text-muted"> |
|||
{{ readingHost(readingHref(it)) }} |
|||
</div> |
|||
</a> |
|||
<div v-else class="rounded-xl border border-default/80 bg-elevated/20 px-4 py-4"> |
|||
<div class="font-semibold text-highlighted"> |
|||
{{ readingTitle(it) }} |
|||
</div> |
|||
</div> |
|||
</template> |
|||
<div v-if="readingData.total > 10" class="flex justify-end"> |
|||
<UPagination |
|||
:page="readingPage" |
|||
:total="readingData.total" |
|||
:items-per-page="10" |
|||
size="sm" |
|||
@update:page="readingPage = $event" |
|||
/> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
</section> |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1,260 @@ |
|||
<script setup lang="ts"> |
|||
type PublicPostListItem = { |
|||
title?: string | null |
|||
excerpt?: string | null |
|||
slug?: string | null |
|||
publishedAt?: Date | string | null |
|||
} |
|||
|
|||
type PublicTimelineItem = { |
|||
id?: number |
|||
title?: string | null |
|||
occurredOn?: Date | string | null |
|||
bodyMarkdown?: string | null |
|||
} |
|||
|
|||
type PublicRssListItem = { |
|||
title?: string | null |
|||
canonicalUrl?: string | null |
|||
canonical_url?: string | null |
|||
} |
|||
|
|||
type NormalizedModule<T> = { |
|||
items: T[] |
|||
total: number |
|||
} |
|||
|
|||
type SocialLink = { |
|||
label: string |
|||
icon?: string |
|||
safeHref: string |
|||
} |
|||
|
|||
const props = defineProps<{ |
|||
slug: string |
|||
displayName: string |
|||
publicSlug: string | null |
|||
avatar: string | null |
|||
bioPreviewText: string |
|||
hasBioPreview: boolean |
|||
socialLinks: SocialLink[] |
|||
postsModule: NormalizedModule<PublicPostListItem> |
|||
timelineModule: NormalizedModule<PublicTimelineItem> |
|||
readingModule: NormalizedModule<PublicRssListItem> |
|||
}>() |
|||
|
|||
function postTitle(it: PublicPostListItem): string { |
|||
const t = it.title |
|||
return typeof t === 'string' && t.trim().length ? t : '未命名文章' |
|||
} |
|||
|
|||
function postExcerpt(it: PublicPostListItem): string { |
|||
return typeof it.excerpt === 'string' ? it.excerpt : '' |
|||
} |
|||
|
|||
function postSlug(it: PublicPostListItem): string { |
|||
return typeof it.slug === 'string' ? it.slug : '' |
|||
} |
|||
|
|||
function timelineTitle(it: PublicTimelineItem): string { |
|||
const t = it.title |
|||
return typeof t === 'string' && t.trim().length ? t : '未命名动态' |
|||
} |
|||
|
|||
function timelineBody(it: PublicTimelineItem): string { |
|||
return typeof it.bodyMarkdown === 'string' ? it.bodyMarkdown : '' |
|||
} |
|||
|
|||
function timelineKey(it: PublicTimelineItem, index: number): string | number { |
|||
return it.id ?? index |
|||
} |
|||
|
|||
function rssHref(it: PublicRssListItem): string | undefined { |
|||
return it.canonicalUrl ?? it.canonical_url ?? undefined |
|||
} |
|||
|
|||
function rssTitle(it: PublicRssListItem): string { |
|||
const t = it.title |
|||
return typeof t === 'string' && t.trim().length ? t : '未命名' |
|||
} |
|||
|
|||
function rssHostname(href: string | undefined): string { |
|||
if (!href) { |
|||
return '' |
|||
} |
|||
try { |
|||
return new URL(href).hostname |
|||
} |
|||
catch { |
|||
return href |
|||
} |
|||
} |
|||
|
|||
const hasModuleContent = computed(() => |
|||
props.postsModule.total > 0 || props.timelineModule.total > 0 || props.readingModule.total > 0, |
|||
) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="mx-auto flex w-full max-w-xl flex-col gap-10 sm:gap-12"> |
|||
<section |
|||
class="w-full rounded-2xl border border-default/80 bg-elevated/25 px-6 py-8 text-center shadow-sm ring-1 ring-black/5 dark:ring-white/10 sm:px-10 sm:py-9" |
|||
aria-label="个人资料" |
|||
> |
|||
<div class="flex flex-col items-center gap-4"> |
|||
<div v-if="avatar" class="flex justify-center"> |
|||
<img |
|||
:src="avatar" |
|||
alt="" |
|||
class="h-24 w-24 rounded-full border-2 border-default object-cover shadow-md ring-4 ring-primary/10" |
|||
> |
|||
</div> |
|||
<div class="space-y-1"> |
|||
<h1 class="text-balance text-2xl font-semibold tracking-tight text-highlighted sm:text-3xl"> |
|||
{{ displayName }} |
|||
</h1> |
|||
<p |
|||
v-if="publicSlug" |
|||
class="text-sm font-medium tabular-nums text-muted" |
|||
> |
|||
@{{ publicSlug }} |
|||
</p> |
|||
</div> |
|||
<ul |
|||
v-if="socialLinks.length" |
|||
class="flex flex-wrap items-center justify-center gap-2.5" |
|||
aria-label="社交链接" |
|||
> |
|||
<li v-for="(l, i) in socialLinks" :key="i"> |
|||
<a |
|||
:href="l.safeHref" |
|||
target="_blank" |
|||
rel="noopener noreferrer" |
|||
:title="l.label" |
|||
:aria-label="`${l.label}(新窗口打开)`" |
|||
class="flex size-10 items-center justify-center rounded-full border border-default/80 bg-default/40 text-primary transition-colors hover:border-primary/35 hover:bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" |
|||
> |
|||
<UIcon |
|||
:name="socialLinkIconName({ ...l, url: l.safeHref })" |
|||
class="size-5 shrink-0 opacity-90" |
|||
/> |
|||
</a> |
|||
</li> |
|||
</ul> |
|||
</div> |
|||
</section> |
|||
|
|||
<section |
|||
v-if="hasBioPreview" |
|||
class="w-full" |
|||
> |
|||
<NuxtLink |
|||
:to="`/@${slug}/about`" |
|||
class="block rounded-2xl border border-default/70 bg-elevated/20 p-5 transition-colors hover:border-primary/25 hover:bg-elevated/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary sm:p-6" |
|||
> |
|||
<p class="text-sm leading-relaxed text-toned sm:text-base"> |
|||
{{ bioPreviewText }} |
|||
</p> |
|||
<p class="mt-3 text-xs font-medium text-primary"> |
|||
查看完整介绍 |
|||
</p> |
|||
</NuxtLink> |
|||
</section> |
|||
|
|||
<template v-if="hasModuleContent"> |
|||
<PublicHubModuleCard |
|||
title="文章" |
|||
:total="postsModule.total" |
|||
description="最近发布的文章摘要,进入后可查看完整内容。" |
|||
:to="`/@${slug}/posts`" |
|||
> |
|||
<template #preview> |
|||
<NuxtLink |
|||
v-for="(p, i) in postsModule.items.slice(0, 2)" |
|||
:key="`${postSlug(p)}-${i}`" |
|||
:to="`/@${slug}/posts/${encodeURIComponent(postSlug(p))}`" |
|||
class="block rounded-lg border border-default/80 bg-default/30 px-3 py-2 transition-colors hover:bg-elevated/40" |
|||
> |
|||
<div class="text-sm font-medium text-highlighted"> |
|||
{{ postTitle(p) }} |
|||
</div> |
|||
<time |
|||
v-if="p.publishedAt" |
|||
class="mt-1 block text-xs tabular-nums text-muted" |
|||
:datetime="occurredOnToIsoAttr(p.publishedAt)" |
|||
>{{ formatPublishedDateOnly(p.publishedAt) }}</time> |
|||
<p v-if="postExcerpt(p)" class="mt-1 line-clamp-2 text-xs text-muted"> |
|||
{{ postExcerpt(p) }} |
|||
</p> |
|||
</NuxtLink> |
|||
</template> |
|||
</PublicHubModuleCard> |
|||
|
|||
<PublicHubModuleCard |
|||
title="时光机" |
|||
:total="timelineModule.total" |
|||
description="近期动态与节点,仅展示入口预览。" |
|||
:to="`/@${slug}/timeline`" |
|||
> |
|||
<template #preview> |
|||
<div |
|||
v-for="(e, i) in timelineModule.items.slice(0, 2)" |
|||
:key="timelineKey(e, i)" |
|||
class="rounded-lg border border-default/80 bg-default/30 px-3 py-2" |
|||
> |
|||
<div class="text-sm font-medium text-highlighted"> |
|||
{{ timelineTitle(e) }} |
|||
</div> |
|||
<time |
|||
v-if="e.occurredOn" |
|||
class="mt-1 block text-xs tabular-nums text-muted" |
|||
:datetime="occurredOnToIsoAttr(e.occurredOn)" |
|||
>{{ formatOccurredOnDisplay(e.occurredOn) }}</time> |
|||
<p v-if="timelineBody(e)" class="mt-1 line-clamp-2 text-xs text-muted"> |
|||
{{ timelineBody(e) }} |
|||
</p> |
|||
</div> |
|||
</template> |
|||
</PublicHubModuleCard> |
|||
|
|||
<PublicHubModuleCard |
|||
title="阅读" |
|||
:total="readingModule.total" |
|||
description="最近收藏/阅读条目,进入阅读页获取完整列表。" |
|||
:to="`/@${slug}/reading`" |
|||
> |
|||
<template #preview> |
|||
<template v-for="(item, i) in readingModule.items.slice(0, 2)" :key="i"> |
|||
<a |
|||
v-if="rssHref(item)" |
|||
:href="rssHref(item)" |
|||
target="_blank" |
|||
rel="noopener noreferrer" |
|||
class="block rounded-lg border border-default/80 bg-default/30 px-3 py-2 transition-colors hover:bg-elevated/40" |
|||
> |
|||
<div class="text-sm font-medium text-highlighted"> |
|||
{{ rssTitle(item) }} |
|||
</div> |
|||
<div class="mt-1 text-xs text-muted"> |
|||
{{ rssHostname(rssHref(item)) }} |
|||
</div> |
|||
</a> |
|||
<div |
|||
v-else |
|||
class="rounded-lg border border-default/80 bg-default/30 px-3 py-2" |
|||
> |
|||
<div class="text-sm font-medium text-highlighted"> |
|||
{{ rssTitle(item) }} |
|||
</div> |
|||
</div> |
|||
</template> |
|||
</template> |
|||
</PublicHubModuleCard> |
|||
</template> |
|||
<UEmpty |
|||
v-else |
|||
title="这里还没有公开内容" |
|||
description="站主尚未发布任何公开文章或动态。" |
|||
/> |
|||
</div> |
|||
</template> |
|||
@ -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 |
|||
<PublicHubModuleCard |
|||
module-key="posts" |
|||
title="文章" |
|||
:total="data.modules.posts.total" |
|||
cta-text="查看全部文章" |
|||
:to="`/@${slug}/posts`" |
|||
:preview-items="data.modules.posts.preview" |
|||
/> |
|||
``` |
|||
|
|||
- [ ] **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` 命名,避免前后不一致。 |
|||
@ -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/<module>` 独立子页 |
|||
- 在 `@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 占位符。 |
|||
- 已检查:信息架构、可见性规则、验收标准之间无明显冲突。 |
|||
- 已检查:范围聚焦于“导航中枢化评审”,未引入无关重构。 |
|||
- 已检查:关键术语(公开/仅链接/私密)定义明确,口径一致。 |
|||
Binary file not shown.
Loading…
Reference in new issue