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