Browse Source

feat(public-profile): switch public home to modular hub layout

Unify public profile aggregation and route canonical handling so the home page acts as a navigation hub with consistent public-only counts, safer external links, and backward-compatible API fields.

Made-with: Cursor
main
npmrun 3 months ago
parent
commit
d9b4d8fe73
  1. 47
      app/components/public-hub/HubModuleCard.vue
  2. 772
      app/pages/@[publicSlug]/index.vue
  3. 12
      app/pages/@[publicSlug]/posts/index.vue
  4. 34
      app/pages/@[publicSlug]/reading/index.vue
  5. 21
      app/pages/@[publicSlug]/timeline/index.vue
  6. 32
      app/utils/public-canonical-url.test.ts
  7. 36
      app/utils/public-canonical-url.ts
  8. 20
      app/utils/safe-external-href.test.ts
  9. 28
      app/utils/safe-external-href.ts
  10. 326
      server/api/public/profile/[publicSlug].get.test.ts
  11. 20
      server/api/public/profile/[publicSlug].get.ts
  12. 229
      server/api/public/profile/[publicSlug]/consistency.test.ts
  13. 168
      server/service/public-hub/index.test.ts
  14. 52
      server/service/public-hub/index.ts

47
app/components/public-hub/HubModuleCard.vue

@ -0,0 +1,47 @@
<script setup lang="ts">
defineProps<{
title: string
total: number
description: string
to: string
}>()
</script>
<template>
<section class="rounded-2xl border border-default/80 bg-elevated/25 p-5 shadow-sm sm:p-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="text-base font-semibold text-highlighted">
{{ title }}
</h2>
<p class="mt-1 text-sm text-muted">
{{ total }}
</p>
</div>
</div>
<p class="mt-4 text-sm leading-relaxed text-muted">
{{ description }}
</p>
<div class="mt-4 space-y-2">
<slot name="preview">
<div class="rounded-lg border border-dashed border-default px-3 py-2 text-sm text-muted">
暂无可预览内容
</div>
</slot>
</div>
<div class="mt-4">
<UButton
:to="to"
variant="outline"
size="sm"
icon="i-lucide-arrow-right"
trailing
>
查看全部
</UButton>
</div>
</section>
</template>

772
app/pages/@[publicSlug]/index.vue

@ -1,29 +1,23 @@
<script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../utils/http/factory'
import { extractFrontMatterDesc, stripFrontMatter } from '../../utils/markdown-front-matter'
import { renderSafeMarkdown } from '../../utils/render-markdown'
import {
formatOccurredOnDisplay,
formatPublishedDateOnly,
occurredOnToIsoAttr,
} from '../../utils/timeline-datetime'
import { buildPublicCanonicalUrl } from '../../utils/public-canonical-url'
import { safeExternalHref } from '../../utils/safe-external-href'
import { formatOccurredOnDisplay, formatPublishedDateOnly, occurredOnToIsoAttr } from '../../utils/timeline-datetime'
definePageMeta({
layout: 'public',
})
const route = useRoute()
const runtimeConfig = useRuntimeConfig()
const slug = computed(() => route.params.publicSlug as string)
const { mode } = usePublicProfileLayoutMode()
type ReadingSection = 'posts' | 'timeline' | 'rss'
const readingSection = ref<ReadingSection>('posts')
type PublicPostListItem = {
title: string
excerpt: string
slug: string
publishedAt: Date | null
title?: string | null
excerpt?: string | null
slug?: string | null
publishedAt?: Date | string | null
coverUrl?: string | null
}
@ -35,20 +29,47 @@ type PublicTimelineItem = {
bodyMarkdown?: string | null
}
type PublicRssListItem = { title?: string | null; canonicalUrl?: string | null; canonical_url?: string | null }
type PublicRssListItem = {
title?: string | null
canonicalUrl?: string | null
canonical_url?: string | null
}
type ModulePayload<T> = {
items?: T[]
total?: number
}
type Payload = {
user: { publicSlug: string | null; nickname: string | null; avatar: string | null }
bio: { markdown: string } | null
links: { label: string; url: string; visibility: string; icon?: string }[]
posts: { items: PublicPostListItem[]; total: number }
timeline: { items: PublicTimelineItem[]; total: number }
rssItems: { items: PublicRssListItem[]; total: number }
links: { label: string; url?: string; href?: string; visibility: string; icon?: string }[]
modules?: {
posts?: ModulePayload<PublicPostListItem>
timeline?: ModulePayload<PublicTimelineItem>
reading?: ModulePayload<PublicRssListItem>
}
posts?: ModulePayload<PublicPostListItem>
timeline?: ModulePayload<PublicTimelineItem>
rssItems?: ModulePayload<PublicRssListItem>
}
type NormalizedModule<T> = {
items: T[]
total: number
}
function normalizeModule<T>(primary?: ModulePayload<T>, fallback?: ModulePayload<T>): NormalizedModule<T> {
const source = primary ?? fallback
return {
items: Array.isArray(source?.items) ? source.items : [],
total: typeof source?.total === 'number' ? source.total : 0,
}
}
function rssPublicHref(it: PublicRssListItem): string | undefined {
const u = it.canonicalUrl ?? it.canonical_url
return typeof u === 'string' && u.trim().length ? u : undefined
return safeExternalHref(u)
}
function rssPublicTitle(it: PublicRssListItem): string {
@ -56,6 +77,40 @@ function rssPublicTitle(it: PublicRssListItem): string {
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 ''
@ -77,85 +132,70 @@ const { data, pending, error } = await useAsyncData(
{ watch: [slug] },
)
function firstReadingSection(d: Payload): ReadingSection {
if (d.posts.total > 0) {
return 'posts'
}
if (d.timeline.total > 0) {
return 'timeline'
}
return 'rss'
}
function readingSectionValid(d: Payload, s: ReadingSection): boolean {
if (s === 'posts') {
return d.posts.total > 0
}
if (s === 'timeline') {
return d.timeline.total > 0
}
return d.rssItems.total > 0
}
watch([data, mode], () => {
if (!data.value || mode.value !== 'detailed') {
return
}
const d = data.value
if (!d.posts.total && !d.timeline.total && !d.rssItems.total) {
return
}
if (!readingSectionValid(d, readingSection.value)) {
readingSection.value = firstReadingSection(d)
}
}, { immediate: true })
function selectReadingSection(s: ReadingSection) {
if (!data.value || !readingSectionValid(data.value, s)) {
return
}
readingSection.value = s
nextTick(() => {
document.getElementById('public-reading-main')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
const postsModule = computed(() => normalizeModule(data.value?.modules?.posts, data.value?.posts))
const timelineModule = computed(() => normalizeModule(data.value?.modules?.timeline, data.value?.timeline))
const readingModule = computed(() => normalizeModule(data.value?.modules?.reading, data.value?.rssItems))
function timelineItemKey(e: PublicTimelineItem, i: number): string | number {
return e.id ?? i
}
const BIO_PREVIEW_MAX_CHARS = 140
/** 主页生平:有 front matter 的 desc 时只展示 desc;否则展示全文(兼容无 FM 的简介) */
const bioHtml = computed(() => {
const bioSummary = computed(() => {
const md = data.value?.bio?.markdown
if (!md?.trim()) {
return ''
}
const desc = extractFrontMatterDesc(md)
if (desc) {
return renderSafeMarkdown(desc)
if (typeof desc === 'string' && desc.trim().length > 0) {
return desc.trim()
}
return renderSafeMarkdown(md)
return stripFrontMatter(md).trim()
})
/** 简介整块可点进 about:去掉 FM 后仍有正文时才包链接(避免仅有 desc 时链到空页) */
const showBioReadMore = computed(() => {
const md = data.value?.bio?.markdown
if (!md?.trim()) {
return false
const bioPreviewText = computed(() => {
const plain = bioSummary.value.replace(/\s+/g, ' ').trim()
if (!plain) {
return ''
}
if (plain.length <= BIO_PREVIEW_MAX_CHARS) {
return plain
}
return stripFrontMatter(md).trim().length > 0
return `${plain.slice(0, BIO_PREVIEW_MAX_CHARS).trimEnd()}...`
})
const hasBioPreview = computed(() => bioPreviewText.value.length > 0)
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,
)
const canonicalUrl = computed(() =>
buildPublicCanonicalUrl(runtimeConfig.public.siteUrl, route.fullPath),
)
useHead(() => ({
link: canonicalUrl.value
? [{ rel: 'canonical', href: canonicalUrl.value }]
: [],
}))
usePageTitle(() => {
const s = slug.value
const d = data.value
if (!d) {
return [`@${s}`, '主页']
}
const name =
d.user.nickname
|| (d.user.publicSlug ? `@${d.user.publicSlug}` : '')
|| s
const name = d.user.nickname || (d.user.publicSlug ? `@${d.user.publicSlug}` : '') || s
return [name, '主页']
})
</script>
@ -167,13 +207,11 @@ usePageTitle(() => {
<UContainer v-else-if="error && !data" class="py-10">
<UAlert color="error" title="无法加载主页" />
</UContainer>
<!-- 展示居中窄栏编辑式层级 + 轻量卡片 -->
<UContainer
v-else-if="data && mode === 'showcase'"
v-else-if="data"
class="max-w-2xl py-10 sm:py-14"
>
<div class="mx-auto flex w-full max-w-xl flex-col gap-12 sm:gap-14">
<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="个人资料"
@ -198,13 +236,13 @@ usePageTitle(() => {
</p>
</div>
<ul
v-if="data.links.length"
v-if="socialLinks.length"
class="flex flex-wrap items-center justify-center gap-2.5"
aria-label="社交链接"
>
<li v-for="(l, i) in data.links" :key="i">
<li v-for="(l, i) in socialLinks" :key="i">
<a
:href="l.url"
:href="l.safeHref"
target="_blank"
rel="noopener noreferrer"
:title="l.label"
@ -212,7 +250,7 @@ usePageTitle(() => {
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)"
:name="socialLinkIconName({ ...l, url: l.safeHref || '' })"
class="size-5 shrink-0 opacity-90"
/>
</a>
@ -222,507 +260,117 @@ usePageTitle(() => {
</section>
<section
v-if="data.bio?.markdown && bioHtml"
v-if="hasBioPreview"
class="w-full"
>
<NuxtLink
v-if="showBioReadMore"
: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"
>
<div
class="prose prose-sm prose-neutral max-w-none dark:prose-invert prose-p:leading-relaxed prose-img:rounded-lg"
v-html="bioHtml"
/>
<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>
<div
v-else
class="rounded-2xl border border-default/70 bg-elevated/20 p-5 sm:p-6"
>
<div
class="prose prose-sm prose-neutral max-w-none dark:prose-invert prose-p:leading-relaxed prose-img:rounded-lg"
v-html="bioHtml"
/>
</div>
</section>
<section v-if="data.posts.total" class="w-full space-y-4">
<div class="flex flex-wrap items-end justify-between gap-3">
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
文章
</h2>
<UButton
v-if="data.posts.total > 5"
:to="`/@${slug}/posts`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.posts.total }}
</UButton>
</div>
<ul class="divide-y divide-default overflow-hidden rounded-xl border border-default">
<li
v-for="p in data.posts.items"
:key="p.slug"
class="bg-default/30 transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-elevated/40"
>
<template v-if="hasModuleContent">
<HubModuleCard
title="文章"
:total="postsModule.total"
description="最近发布的文章摘要,进入后可查看完整内容。"
:to="`/@${slug}/posts`"
>
<template #preview>
<NuxtLink
:to="`/@${slug}/posts/${encodeURIComponent(p.slug)}`"
class="group flex cursor-pointer gap-4 p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary sm:gap-5"
v-for="(p, i) in postsModule.items.slice(0, 2)"
:key="`${postPublicSlug(p)}-${i}`"
:to="`/@${slug}/posts/${encodeURIComponent(postPublicSlug(p))}`"
class="block rounded-lg border border-default/80 bg-default/30 px-3 py-2 transition-colors hover:bg-elevated/40"
>
<div
v-if="p.coverUrl"
class="relative h-[4.5rem] w-[6.5rem] shrink-0 overflow-hidden rounded-lg border border-default/80"
>
<img
:src="p.coverUrl"
alt=""
class="size-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
>
</div>
<div class="min-w-0 flex-1">
<time
v-if="p.publishedAt"
class="block text-xs font-medium tabular-nums text-muted"
:datetime="occurredOnToIsoAttr(p.publishedAt)"
>{{ formatPublishedDateOnly(p.publishedAt) }}</time>
<div class="mt-1 text-pretty font-semibold text-highlighted transition-colors group-hover:text-primary">
{{ p.title }}
</div>
<p
v-if="p.excerpt"
class="mt-1.5 line-clamp-2 text-sm leading-relaxed text-muted"
>
{{ p.excerpt }}
</p>
<div class="text-sm font-medium text-highlighted">
{{ postPublicTitle(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="postPublicExcerpt(p)" class="mt-1 line-clamp-2 text-xs text-muted">
{{ postPublicExcerpt(p) }}
</p>
</NuxtLink>
</li>
</ul>
</section>
<section v-if="data.timeline.total" class="w-full space-y-4">
<div class="flex flex-wrap items-end justify-between gap-3">
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
时光机
</h2>
<UButton
v-if="data.timeline.total > 5"
:to="`/@${slug}/timeline`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.timeline.total }}
</UButton>
</div>
<ul class="relative space-y-0">
<li
v-for="(e, i) in data.timeline.items"
:key="timelineItemKey(e, i)"
class="relative flex gap-4 pb-8 pl-1 last:pb-0 sm:pb-9"
>
</template>
</HubModuleCard>
<HubModuleCard
title="时光机"
:total="timelineModule.total"
description="近期动态与节点,仅展示入口预览。"
:to="`/@${slug}/timeline`"
>
<template #preview>
<div
v-if="i < data.timeline.items.length - 1"
class="absolute bottom-0 left-[11px] top-5 w-px bg-default"
aria-hidden="true"
/>
<div class="relative z-[1] flex shrink-0 flex-col items-center pt-0.5">
<span class="size-2.5 rounded-full bg-primary shadow-sm ring-[6px] ring-primary/12" />
</div>
<article
class="min-w-0 flex-1 rounded-xl border border-default/80 bg-elevated/30 px-4 py-4 shadow-sm sm:px-5 sm:py-4"
v-for="(e, i) in timelineModule.items.slice(0, 2)"
:key="timelineItemKey(e, i)"
class="rounded-lg border border-default/80 bg-default/30 px-3 py-2"
>
<div class="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
<h3 class="order-2 text-pretty text-base font-semibold text-highlighted sm:order-1 sm:min-w-0 sm:flex-1">
{{ e.title }}
</h3>
<time
class="order-1 shrink-0 text-xs font-medium tabular-nums text-muted sm:order-2 sm:text-right"
:datetime="e.occurredOn ? occurredOnToIsoAttr(e.occurredOn) : undefined"
>{{ formatOccurredOnDisplay(e.occurredOn ?? '') }}</time>
<div class="text-sm font-medium text-highlighted">
{{ timelinePublicTitle(e) }}
</div>
<p
v-if="e.bodyMarkdown && e.bodyMarkdown.trim()"
class="mt-3 line-clamp-3 border-t border-default/50 pt-3 text-sm leading-relaxed text-muted"
>
{{ e.bodyMarkdown }}
<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="timelinePublicBody(e)" class="mt-1 line-clamp-2 text-xs text-muted">
{{ timelinePublicBody(e) }}
</p>
<a
v-if="e.linkUrl"
:href="e.linkUrl"
target="_blank"
rel="noopener noreferrer"
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
<span>相关链接</span>
<UIcon name="i-lucide-external-link" class="size-3.5 opacity-80" />
</a>
</article>
</li>
</ul>
</section>
<section v-if="data.rssItems.total" class="w-full space-y-4">
<div class="flex flex-wrap items-end justify-between gap-3">
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
阅读
</h2>
<UButton
v-if="data.rssItems.total > 5"
:to="`/@${slug}/reading`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.rssItems.total }}
</UButton>
</div>
<ul class="divide-y divide-default overflow-hidden rounded-xl border border-default">
<li
v-for="(it, i) in data.rssItems.items"
:key="i"
class="bg-default/30 transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-elevated/40"
>
<a
v-if="rssPublicHref(it)"
:href="rssPublicHref(it)"
target="_blank"
rel="noopener noreferrer"
class="group block p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
>
<div class="text-pretty font-semibold text-primary transition-colors group-hover:underline">
{{ rssPublicTitle(it) }}
</div>
<div class="mt-1 break-all text-sm text-muted">
{{ rssHostname(rssPublicHref(it)) }}
</div>
</a>
<div v-else class="p-4 text-muted">
{{ rssPublicTitle(it) }}
</div>
</li>
</ul>
</section>
<UEmpty
v-if="!data.posts.total && !data.timeline.total && !data.rssItems.total && !data.bio && !data.links.length"
title="这里还没有公开内容"
description="站主尚未发布任何公开文章或动态。"
/>
</div>
</UContainer>
<!-- 阅读侧栏 + 与展示一致的预览块 -->
<UContainer
v-else-if="data && mode === 'detailed'"
class="py-8 lg:py-10 max-w-6xl"
>
<div class="lg:grid lg:grid-cols-[12.5rem_minmax(0,1fr)] lg:gap-10 xl:grid-cols-[14rem_minmax(0,1fr)] xl:gap-12">
<aside class="mb-10 lg:mb-0">
<div class="lg:sticky lg:top-20 space-y-6">
<div class="flex flex-col gap-3 border-b border-default pb-6 lg:border-0 lg:pb-0">
<div v-if="data.user.avatar" class="flex justify-center lg:justify-start">
<img
:src="data.user.avatar"
alt=""
class="h-16 w-16 rounded-full object-cover border border-default lg:h-14 lg:w-14"
>
</div>
<h1 class="text-center text-lg font-semibold text-highlighted lg:text-left lg:text-base">
{{ data.user.nickname || data.user.publicSlug || slug }}
</h1>
<div v-if="data.bio?.markdown && bioHtml" class="space-y-2">
<NuxtLink
v-if="showBioReadMore"
:to="`/@${slug}/about`"
class="block cursor-pointer rounded-lg transition-colors hover:bg-elevated/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<div
class="bio-preview-scroll max-h-36 overflow-y-auto rounded-lg border border-default bg-elevated/30 p-3 text-xs leading-relaxed text-muted prose prose-neutral dark:prose-invert max-w-none prose-p:my-1 prose-img:rounded"
v-html="bioHtml"
/>
</NuxtLink>
<div
v-else
class="bio-preview-scroll max-h-36 overflow-y-auto rounded-lg border border-default bg-elevated/30 p-3 text-xs leading-relaxed text-muted prose prose-neutral dark:prose-invert max-w-none prose-p:my-1 prose-img:rounded"
v-html="bioHtml"
/>
</div>
<div v-if="data.links.length" class="space-y-1.5">
<div class="text-xs font-medium uppercase tracking-wide text-muted">
链接
</div>
<ul class="flex flex-col gap-1">
<li v-for="(l, i) in data.links" :key="i">
<a
:href="l.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex max-w-full items-center gap-1.5 rounded py-0.5 text-xs font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<UIcon
:name="socialLinkIconName(l)"
class="size-3.5 shrink-0 text-primary opacity-90"
aria-hidden="true"
/>
<span class="min-w-0 break-words text-highlighted">{{ l.label }}</span>
</a>
</li>
</ul>
</div>
</div>
<nav
class="flex flex-wrap gap-2 border-t border-default pt-4 lg:hidden"
aria-label="页面区块"
>
<button
v-if="data.posts.total"
type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors"
:class="readingSection === 'posts' ? 'border-primary/40 bg-primary/10 text-highlighted' : 'border-default bg-elevated/40 text-muted hover:text-default'"
@click="selectReadingSection('posts')"
>
文章 · {{ data.posts.total }}
</button>
<button
v-if="data.timeline.total"
type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors"
:class="readingSection === 'timeline' ? 'border-primary/40 bg-primary/10 text-highlighted' : 'border-default bg-elevated/40 text-muted hover:text-default'"
@click="selectReadingSection('timeline')"
>
时光机 · {{ data.timeline.total }}
</button>
<button
v-if="data.rssItems.total"
type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors"
:class="readingSection === 'rss' ? 'border-primary/40 bg-primary/10 text-highlighted' : 'border-default bg-elevated/40 text-muted hover:text-default'"
@click="selectReadingSection('rss')"
>
阅读 · {{ data.rssItems.total }}
</button>
</nav>
<nav class="hidden lg:block space-y-1 border-t border-default pt-4 text-sm" aria-label="页面区块">
<button
v-if="data.posts.total"
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-2 text-left transition-colors"
:class="readingSection === 'posts' ? 'bg-elevated text-highlighted' : 'text-muted hover:bg-elevated/60 hover:text-default'"
@click="selectReadingSection('posts')"
>
<span>文章</span>
<span class="tabular-nums text-xs opacity-80">{{ data.posts.total }}</span>
</button>
<button
v-if="data.timeline.total"
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-2 text-left transition-colors"
:class="readingSection === 'timeline' ? 'bg-elevated text-highlighted' : 'text-muted hover:bg-elevated/60 hover:text-default'"
@click="selectReadingSection('timeline')"
>
<span>时光机</span>
<span class="tabular-nums text-xs opacity-80">{{ data.timeline.total }}</span>
</button>
<button
v-if="data.rssItems.total"
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-2 text-left transition-colors"
:class="readingSection === 'rss' ? 'bg-elevated text-highlighted' : 'text-muted hover:bg-elevated/60 hover:text-default'"
@click="selectReadingSection('rss')"
>
<span>阅读</span>
<span class="tabular-nums text-xs opacity-80">{{ data.rssItems.total }}</span>
</button>
</nav>
</div>
</aside>
<div id="public-reading-main" class="min-w-0 scroll-mt-24 space-y-14">
<section
v-show="readingSection === 'posts' && data.posts.total"
id="public-reading-posts"
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
文章
</h2>
<UButton
v-if="data.posts.total > 5"
:to="`/@${slug}/posts`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.posts.total }}
</UButton>
</div>
<ul class="border-t border-default">
<li
v-for="p in data.posts.items"
:key="p.slug"
class="border-b border-default last:border-b-0"
>
<NuxtLink
:to="`/@${slug}/posts/${encodeURIComponent(p.slug)}`"
class="group flex cursor-pointer flex-col gap-4 py-6 transition-colors hover:bg-elevated/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-default rounded-lg sm:flex-row sm:gap-5"
>
<div
v-if="p.coverUrl"
class="w-full shrink-0 overflow-hidden rounded-xl border border-default sm:w-40"
>
<img
:src="p.coverUrl"
alt=""
class="aspect-[16/9] w-full object-cover sm:aspect-[4/3] sm:min-h-[7rem] sm:h-full"
>
</div>
<div class="min-w-0 flex-1">
<time
v-if="p.publishedAt"
class="block text-xs font-medium tabular-nums text-muted"
:datetime="occurredOnToIsoAttr(p.publishedAt)"
>{{ formatPublishedDateOnly(p.publishedAt) }}</time>
<div class="mt-1 text-xl font-semibold text-pretty text-highlighted leading-snug group-hover:text-primary transition-colors">
{{ p.title }}
</div>
<p
v-if="p.excerpt"
class="mt-3 text-sm text-muted text-pretty leading-relaxed"
>
{{ p.excerpt }}
</p>
</div>
</NuxtLink>
</li>
</ul>
</section>
<section
v-show="readingSection === 'timeline' && data.timeline.total"
id="public-reading-timeline"
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
时光机
</h2>
<UButton
v-if="data.timeline.total > 5"
:to="`/@${slug}/timeline`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.timeline.total }}
</UButton>
</div>
<ul class="relative space-y-0">
<li
v-for="(e, i) in data.timeline.items"
:key="timelineItemKey(e, i)"
class="relative flex gap-4 pb-6 pl-1 last:pb-0"
>
<div
v-if="i < data.timeline.items.length - 1"
class="absolute left-[11px] top-5 bottom-0 w-px bg-default"
aria-hidden="true"
/>
<div class="relative z-[1] flex shrink-0 flex-col items-center pt-0.5">
<span class="size-2.5 rounded-full bg-primary ring-4 ring-primary/15" />
</div>
<article
class="min-w-0 flex-1 rounded-xl border border-default bg-elevated/25 px-5 py-5 shadow-sm sm:px-6"
>
<div class="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
<time
class="shrink-0 text-xs font-medium tabular-nums text-muted sm:order-2 sm:text-right"
:datetime="e.occurredOn ? occurredOnToIsoAttr(e.occurredOn) : undefined"
>{{ formatOccurredOnDisplay(e.occurredOn ?? '') }}</time>
<h3 class="text-pretty text-lg font-semibold text-highlighted sm:order-1 sm:min-w-0 sm:flex-1">
{{ e.title }}
</h3>
</div>
<p
v-if="e.bodyMarkdown && e.bodyMarkdown.trim()"
class="mt-4 whitespace-pre-wrap border-t border-default/60 pt-4 text-sm leading-relaxed text-default text-pretty"
>
{{ e.bodyMarkdown }}
</p>
<a
v-if="e.linkUrl"
:href="e.linkUrl"
target="_blank"
rel="noopener noreferrer"
class="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
<span>打开链接</span>
<UIcon name="i-lucide-external-link" class="size-3.5 opacity-80" />
</a>
</article>
</li>
</ul>
</section>
<section
v-show="readingSection === 'rss' && data.rssItems.total"
id="public-reading-rss"
</template>
</HubModuleCard>
<HubModuleCard
title="阅读"
:total="readingModule.total"
description="最近收藏/阅读条目,进入阅读页获取完整列表。"
:to="`/@${slug}/reading`"
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
阅读
</h2>
<UButton
v-if="data.rssItems.total > 5"
:to="`/@${slug}/reading`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.rssItems.total }}
</UButton>
</div>
<ul class="border-t border-default">
<li
v-for="(it, i) in data.rssItems.items"
:key="i"
class="border-b border-default last:border-b-0"
>
<template #preview>
<template v-for="({ item, href }, i) in readingPreviewItems" :key="i">
<a
v-if="rssPublicHref(it)"
:href="rssPublicHref(it)"
v-if="href"
:href="href"
target="_blank"
rel="noopener noreferrer"
class="group block py-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
class="block rounded-lg border border-default/80 bg-default/30 px-3 py-2 transition-colors hover:bg-elevated/40"
>
<div class="text-base font-semibold text-pretty text-primary group-hover:underline">
{{ rssPublicTitle(it) }}
<div class="text-sm font-medium text-highlighted">
{{ rssPublicTitle(item) }}
</div>
<div class="mt-1.5 break-all text-sm text-muted">
{{ rssHostname(rssPublicHref(it)) }}
<div class="mt-1 text-xs text-muted">
{{ rssHostname(href) }}
</div>
</a>
<div v-else class="py-5 text-muted">
{{ rssPublicTitle(it) }}
<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">
{{ rssPublicTitle(item) }}
</div>
</div>
</li>
</ul>
</section>
<UEmpty
v-if="!data.posts.total && !data.timeline.total && !data.rssItems.total && !data.bio && !data.links.length"
title="这里还没有公开内容"
description="站主尚未发布任何公开文章或动态。"
/>
</div>
</template>
</template>
</HubModuleCard>
</template>
<UEmpty
v-else
title="这里还没有公开内容"
description="站主尚未发布任何公开文章或动态。"
/>
</div>
</UContainer>
</template>

12
app/pages/@[publicSlug]/posts/index.vue

@ -1,5 +1,6 @@
<script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../../utils/http/factory'
import { buildPublicCanonicalUrl } from '../../../utils/public-canonical-url'
import {
formatPublishedDateOnly,
occurredOnToIsoAttr,
@ -11,6 +12,7 @@ definePageMeta({
const route = useRoute()
const router = useRouter()
const runtimeConfig = useRuntimeConfig()
const slug = computed(() => route.params.publicSlug as string)
function parsePage(raw: unknown): number {
@ -73,6 +75,16 @@ const { data, pending, error } = await useAsyncData(
{ watch: [slug, page] },
)
const canonicalUrl = computed(() =>
buildPublicCanonicalUrl(runtimeConfig.public.siteUrl, route.fullPath),
)
useHead(() => ({
link: canonicalUrl.value
? [{ rel: 'canonical', href: canonicalUrl.value }]
: [],
}))
usePageTitle(() => {
const parts: string[] = []
if (page.value > 1) {

34
app/pages/@[publicSlug]/reading/index.vue

@ -1,5 +1,7 @@
<script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../../utils/http/factory'
import { buildPublicCanonicalUrl } from '../../../utils/public-canonical-url'
import { safeExternalHref } from '../../../utils/safe-external-href'
definePageMeta({
layout: 'public',
@ -7,6 +9,7 @@ definePageMeta({
const route = useRoute()
const router = useRouter()
const runtimeConfig = useRuntimeConfig()
const slug = computed(() => route.params.publicSlug as string)
function parsePage(raw: unknown): number {
@ -42,7 +45,7 @@ type Payload = {
function rssPublicHref(it: PublicRssListItem): string | undefined {
const u = it.canonicalUrl ?? it.canonical_url
return typeof u === 'string' && u.trim().length ? u : undefined
return safeExternalHref(u)
}
function rssPublicTitle(it: PublicRssListItem): string {
@ -85,6 +88,23 @@ const { data, pending, error } = await useAsyncData(
{ watch: [slug, page] },
)
const readingItems = computed(() =>
(data.value?.items ?? []).map(item => ({
item,
href: rssPublicHref(item),
})),
)
const canonicalUrl = computed(() =>
buildPublicCanonicalUrl(runtimeConfig.public.siteUrl, route.fullPath),
)
useHead(() => ({
link: canonicalUrl.value
? [{ rel: 'canonical', href: canonicalUrl.value }]
: [],
}))
usePageTitle(() => {
const parts: string[] = []
if (page.value > 1) {
@ -118,26 +138,26 @@ usePageTitle(() => {
</h2>
<ul class="border-t border-default">
<li
v-for="(it, i) in data.items"
v-for="({ item, href }, i) in readingItems"
:key="i"
class="border-b border-default last:border-b-0"
>
<a
v-if="rssPublicHref(it)"
:href="rssPublicHref(it)"
v-if="href"
:href="href"
target="_blank"
rel="noopener noreferrer"
class="group block py-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
>
<div class="text-base font-semibold text-pretty text-primary group-hover:underline">
{{ rssPublicTitle(it) }}
{{ rssPublicTitle(item) }}
</div>
<div class="mt-1.5 break-all text-sm text-muted">
{{ rssHostname(rssPublicHref(it)) }}
{{ rssHostname(href) }}
</div>
</a>
<div v-else class="py-5 text-muted">
{{ rssPublicTitle(it) }}
{{ rssPublicTitle(item) }}
</div>
</li>
</ul>

21
app/pages/@[publicSlug]/timeline/index.vue

@ -1,5 +1,7 @@
<script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../../utils/http/factory'
import { buildPublicCanonicalUrl } from '../../../utils/public-canonical-url'
import { safeExternalHref } from '../../../utils/safe-external-href'
import {
formatOccurredOnDisplay,
occurredOnToIsoAttr,
@ -11,6 +13,7 @@ definePageMeta({
const route = useRoute()
const router = useRouter()
const runtimeConfig = useRuntimeConfig()
const slug = computed(() => route.params.publicSlug as string)
function parsePage(raw: unknown): number {
@ -54,6 +57,10 @@ function timelineItemKey(e: PublicTimelineItem, i: number): string | number {
return e.id ?? i
}
function timelineLinkHref(linkUrl: unknown): string | undefined {
return safeExternalHref(linkUrl)
}
function onPageChange(p: number) {
page.value = p
const query: Record<string, string | string[] | undefined> = { ...route.query }
@ -77,6 +84,16 @@ const { data, pending, error } = await useAsyncData(
{ watch: [slug, page] },
)
const canonicalUrl = computed(() =>
buildPublicCanonicalUrl(runtimeConfig.public.siteUrl, route.fullPath),
)
useHead(() => ({
link: canonicalUrl.value
? [{ rel: 'canonical', href: canonicalUrl.value }]
: [],
}))
usePageTitle(() => {
const parts: string[] = []
if (page.value > 1) {
@ -141,8 +158,8 @@ usePageTitle(() => {
{{ e.bodyMarkdown }}
</p>
<a
v-if="e.linkUrl"
:href="e.linkUrl"
v-if="timelineLinkHref(e.linkUrl)"
:href="timelineLinkHref(e.linkUrl)"
target="_blank"
rel="noopener noreferrer"
class="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"

32
app/utils/public-canonical-url.test.ts

@ -0,0 +1,32 @@
import { describe, expect, test } from 'bun:test'
import { buildPublicCanonicalUrl } from './public-canonical-url'
describe('buildPublicCanonicalUrl', () => {
test('builds canonical with full path', () => {
expect(buildPublicCanonicalUrl('https://example.com', '/@alice/posts?page=2')).toBe('https://example.com/@alice/posts?page=2')
})
test('drops hash fragment', () => {
expect(buildPublicCanonicalUrl('https://example.com', '/@alice/reading#section')).toBe('https://example.com/@alice/reading')
})
test('removes page=1 from canonical', () => {
expect(buildPublicCanonicalUrl('https://example.com', '/@alice/timeline?page=1')).toBe('https://example.com/@alice/timeline')
})
test('removes page=1 equivalent query forms from canonical', () => {
expect(buildPublicCanonicalUrl('https://example.com', '/@alice/timeline?page=01')).toBe('https://example.com/@alice/timeline')
expect(buildPublicCanonicalUrl('https://example.com', '/@alice/timeline?page=%2B1')).toBe('https://example.com/@alice/timeline')
expect(buildPublicCanonicalUrl('https://example.com', '/@alice/timeline?foo=bar&page=1.0')).toBe('https://example.com/@alice/timeline?foo=bar')
expect(buildPublicCanonicalUrl('https://example.com', '/@alice/timeline?page=1&page=001')).toBe('https://example.com/@alice/timeline')
})
test('keeps non-first-page values', () => {
expect(buildPublicCanonicalUrl('https://example.com', '/@alice/timeline?page=2')).toBe('https://example.com/@alice/timeline?page=2')
expect(buildPublicCanonicalUrl('https://example.com', '/@alice/timeline?page=1&page=2')).toBe('https://example.com/@alice/timeline?page=1&page=2')
})
test('returns null when siteUrl is invalid', () => {
expect(buildPublicCanonicalUrl('not-a-url', '/@alice')).toBeNull()
})
})

36
app/utils/public-canonical-url.ts

@ -0,0 +1,36 @@
function isFirstPageParamValue(raw: string): boolean {
const normalized = raw.trim()
if (!normalized) {
return false
}
if (/^\+?0*1(?:\.0+)?$/.test(normalized)) {
return true
}
const parsed = Number(normalized)
return Number.isFinite(parsed) && parsed === 1
}
export function buildPublicCanonicalUrl(siteUrlRaw: unknown, currentFullPath: unknown): string | null {
if (typeof siteUrlRaw !== 'string') {
return null
}
const siteUrl = siteUrlRaw.trim()
if (!siteUrl) {
return null
}
try {
const base = new URL(siteUrl)
const path = typeof currentFullPath === 'string' && currentFullPath.trim()
? currentFullPath.split('#', 1)[0]!
: '/'
const canonical = new URL(path, `${base.origin}/`)
const pageValues = canonical.searchParams.getAll('page')
if (pageValues.length > 0 && pageValues.every(isFirstPageParamValue)) {
canonical.searchParams.delete('page')
}
return canonical.href
}
catch {
return null
}
}

20
app/utils/safe-external-href.test.ts

@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { safeExternalHref } from "./safe-external-href";
describe("safeExternalHref", () => {
test("allows https urls", () => {
expect(safeExternalHref("https://example.com/path?q=1")).toBe("https://example.com/path?q=1");
});
test("rejects javascript/data/ftp protocols", () => {
expect(safeExternalHref("javascript:alert(1)")).toBeUndefined();
expect(safeExternalHref("data:text/html,hello")).toBeUndefined();
expect(safeExternalHref("ftp://example.com/file.txt")).toBeUndefined();
});
test("supports mailto only when allowMailto=true", () => {
const mailto = "mailto:user@example.com?subject=Hello";
expect(safeExternalHref(mailto)).toBeUndefined();
expect(safeExternalHref(mailto, { allowMailto: true })).toBe(mailto);
});
});

28
app/utils/safe-external-href.ts

@ -0,0 +1,28 @@
type SafeExternalHrefOptions = {
allowMailto?: boolean
}
export function safeExternalHref(raw: unknown, options: SafeExternalHrefOptions = {}): string | undefined {
if (typeof raw !== 'string') {
return undefined
}
const value = raw.trim()
if (!value) {
return undefined
}
try {
const url = new URL(value)
const protocol = url.protocol.toLowerCase()
if (protocol === 'http:' || protocol === 'https:') {
return url.href
}
if (options.allowMailto && protocol === 'mailto:') {
return url.href
}
return undefined
}
catch {
return undefined
}
}

326
server/api/public/profile/[publicSlug].get.test.ts

@ -0,0 +1,326 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
const ownerRecord = {
publicSlug: "demo-user",
nickname: "Demo",
avatar: "https://cdn.example.com/avatar.png",
avatarVisibility: "public",
bioVisibility: "public",
bioMarkdown: "hello bio",
socialLinksJson: "[]",
status: "active",
};
const selectMock = mock(() => ({
from: () => ({
where: () => ({
limit: async () => [ownerRecord],
}),
}),
}));
const getPublicHubBySlug = mock(async () => ({
modules: {
posts: {
items: [
{ slug: "hub-p1", title: "hub-post-1" },
{ slug: "hub-p2", title: "hub-post-2" },
],
total: 6,
},
timeline: {
items: [
{ id: 101, title: "hub-t1" },
{ id: 102, title: "hub-t2" },
],
total: 8,
},
reading: {
items: [
{ id: 2, title: "r1" },
{ id: 3, title: "r2" },
],
total: 6,
},
},
posts: {
items: [
{ slug: "legacy-p1", title: "legacy-post-1" },
{ slug: "legacy-p2", title: "legacy-post-2" },
{ slug: "legacy-p3", title: "legacy-post-3" },
{ slug: "legacy-p4", title: "legacy-post-4" },
],
total: 9,
},
timeline: {
items: [
{ id: 201, title: "legacy-t1" },
{ id: 202, title: "legacy-t2" },
{ id: 203, title: "legacy-t3" },
],
total: 11,
},
rssItems: {
items: [
{ id: 2, title: "r1" },
{ id: 3, title: "r2" },
{ id: 4, title: "r3" },
{ id: 5, title: "r4" },
{ id: 6, title: "r5" },
],
total: 6,
},
}));
const parseSocialLinksJson = mock(() => [
{ label: "x", href: "https://x.com/demo", visibility: "public" },
{ label: "mail", href: "mailto:demo@example.com", visibility: "private" },
]);
mock.module("drizzle-pkg/lib/db", () => ({
dbGlobal: {
select: selectMock,
},
}));
mock.module("drizzle-pkg/lib/schema/auth", () => ({
users: {
publicSlug: "publicSlug",
status: "status",
},
}));
mock.module("drizzle-orm", () => ({
and: (...conditions: unknown[]) => conditions,
eq: (a: unknown, b: unknown) => [a, b],
}));
mock.module("#server/service/profile", () => ({
parseSocialLinksJson,
}));
mock.module("#server/service/public-hub", () => ({
getPublicHubBySlug,
}));
globalThis.defineEventHandler ??= ((handler: unknown) => handler) as never;
globalThis.createError ??= ((input: { statusCode: number; statusMessage: string }) => {
const error = new Error(input.statusMessage) as Error & {
statusCode: number;
statusMessage: string;
};
error.statusCode = input.statusCode;
error.statusMessage = input.statusMessage;
return error;
}) as never;
globalThis.R ??= {
success: (data: unknown) => data,
};
const handler = (await import("./[publicSlug].get")).default;
describe("GET /api/public/profile/:publicSlug", () => {
beforeEach(() => {
ownerRecord.publicSlug = "demo-user";
ownerRecord.nickname = "Demo";
ownerRecord.avatar = "https://cdn.example.com/avatar.png";
ownerRecord.avatarVisibility = "public";
ownerRecord.bioVisibility = "public";
ownerRecord.bioMarkdown = "hello bio";
ownerRecord.socialLinksJson = "[]";
ownerRecord.status = "active";
selectMock.mockClear();
getPublicHubBySlug.mockClear();
parseSocialLinksJson.mockClear();
});
test("通过 public-hub 一次调用返回 modules 与 legacy 字段", async () => {
const result = await handler({
context: {
params: {
publicSlug: "demo-user",
},
},
} as never);
expect(getPublicHubBySlug).toHaveBeenCalledWith("demo-user");
expect(getPublicHubBySlug).toHaveBeenCalledTimes(1);
expect(parseSocialLinksJson).toHaveBeenCalledTimes(1);
expect(selectMock).toHaveBeenCalledTimes(1);
expect(result).toEqual({
user: {
publicSlug: "demo-user",
nickname: "Demo",
avatar: "https://cdn.example.com/avatar.png",
},
bio: {
markdown: "hello bio",
},
links: [{ label: "x", href: "https://x.com/demo", visibility: "public" }],
modules: {
posts: {
items: [
{ slug: "hub-p1", title: "hub-post-1" },
{ slug: "hub-p2", title: "hub-post-2" },
],
total: 6,
},
timeline: {
items: [
{ id: 101, title: "hub-t1" },
{ id: 102, title: "hub-t2" },
],
total: 8,
},
reading: {
items: [
{ id: 2, title: "r1" },
{ id: 3, title: "r2" },
],
total: 6,
},
},
posts: {
items: [
{ slug: "legacy-p1", title: "legacy-post-1" },
{ slug: "legacy-p2", title: "legacy-post-2" },
{ slug: "legacy-p3", title: "legacy-post-3" },
{ slug: "legacy-p4", title: "legacy-post-4" },
],
total: 9,
},
timeline: {
items: [
{ id: 201, title: "legacy-t1" },
{ id: 202, title: "legacy-t2" },
{ id: 203, title: "legacy-t3" },
],
total: 11,
},
rssItems: {
items: [
{ id: 2, title: "r1" },
{ id: 3, title: "r2" },
{ id: 4, title: "r3" },
{ id: 5, title: "r4" },
{ id: 6, title: "r5" },
],
total: 6,
},
});
});
test("publicSlug 缺失时返回 400,且不触发下游 service", async () => {
await expect(
handler({
context: {
params: {},
},
} as never),
).rejects.toMatchObject({
statusCode: 400,
statusMessage: "无效主页",
});
expect(selectMock).toHaveBeenCalledTimes(0);
expect(parseSocialLinksJson).toHaveBeenCalledTimes(0);
expect(getPublicHubBySlug).toHaveBeenCalledTimes(0);
});
test("publicSlug 非法时返回 400,且不触发下游 service", async () => {
await expect(
handler({
context: {
params: {
publicSlug: 123,
},
},
} as never),
).rejects.toMatchObject({
statusCode: 400,
statusMessage: "无效主页",
});
expect(selectMock).toHaveBeenCalledTimes(0);
expect(parseSocialLinksJson).toHaveBeenCalledTimes(0);
expect(getPublicHubBySlug).toHaveBeenCalledTimes(0);
});
test("owner 不存在或非 active 时返回 404", async () => {
selectMock.mockImplementationOnce(() => ({
from: () => ({
where: () => ({
limit: async () => [],
}),
}),
}));
await expect(
handler({
context: {
params: {
publicSlug: "demo-user",
},
},
} as never),
).rejects.toMatchObject({
statusCode: 404,
statusMessage: "未找到",
});
expect(selectMock).toHaveBeenCalledTimes(1);
expect(parseSocialLinksJson).toHaveBeenCalledTimes(0);
expect(getPublicHubBySlug).toHaveBeenCalledTimes(0);
});
test("avatarVisibility 非 public 时 avatar 为 null", async () => {
ownerRecord.avatarVisibility = "private";
const result = await handler({
context: {
params: {
publicSlug: "demo-user",
},
},
} as never);
expect(result.user.avatar).toBeNull();
expect(selectMock).toHaveBeenCalledTimes(1);
expect(parseSocialLinksJson).toHaveBeenCalledTimes(1);
expect(getPublicHubBySlug).toHaveBeenCalledTimes(1);
});
test("bioVisibility 非 public 时 bio 为 null", async () => {
ownerRecord.bioVisibility = "private";
const result = await handler({
context: {
params: {
publicSlug: "demo-user",
},
},
} as never);
expect(result.bio).toBeNull();
expect(selectMock).toHaveBeenCalledTimes(1);
expect(parseSocialLinksJson).toHaveBeenCalledTimes(1);
expect(getPublicHubBySlug).toHaveBeenCalledTimes(1);
});
test("无 bioMarkdown 时 bio 为 null", async () => {
ownerRecord.bioMarkdown = "";
const result = await handler({
context: {
params: {
publicSlug: "demo-user",
},
},
} as never);
expect(result.bio).toBeNull();
expect(selectMock).toHaveBeenCalledTimes(1);
expect(parseSocialLinksJson).toHaveBeenCalledTimes(1);
expect(getPublicHubBySlug).toHaveBeenCalledTimes(1);
});
});

20
server/api/public/profile/[publicSlug].get.ts

@ -1,9 +1,7 @@
import { dbGlobal } from "drizzle-pkg/lib/db";
import { users } from "drizzle-pkg/lib/schema/auth";
import { and, eq } from "drizzle-orm";
import { getPublicPostsPreviewBySlug } from "#server/service/posts";
import { getPublicTimelinePreviewBySlug } from "#server/service/timeline";
import { getPublicRssPreviewBySlug } from "#server/service/rss";
import { getPublicHubBySlug } from "#server/service/public-hub";
import { parseSocialLinksJson } from "#server/service/profile";
export default defineEventHandler(async (event) => {
@ -24,6 +22,8 @@ export default defineEventHandler(async (event) => {
const links = parseSocialLinksJson(owner.socialLinksJson).filter((l) => l.visibility === "public");
const hub = await getPublicHubBySlug(publicSlug);
const payload: {
user: {
publicSlug: string | null;
@ -32,9 +32,10 @@ export default defineEventHandler(async (event) => {
};
bio: { markdown: string | null } | null;
links: typeof links;
posts: Awaited<ReturnType<typeof getPublicPostsPreviewBySlug>>;
timeline: Awaited<ReturnType<typeof getPublicTimelinePreviewBySlug>>;
rssItems: Awaited<ReturnType<typeof getPublicRssPreviewBySlug>>;
modules: Awaited<ReturnType<typeof getPublicHubBySlug>>["modules"];
posts: Awaited<ReturnType<typeof getPublicHubBySlug>>["posts"];
timeline: Awaited<ReturnType<typeof getPublicHubBySlug>>["timeline"];
rssItems: Awaited<ReturnType<typeof getPublicHubBySlug>>["rssItems"];
} = {
user: {
publicSlug: owner.publicSlug,
@ -43,9 +44,10 @@ export default defineEventHandler(async (event) => {
},
bio: null,
links,
posts: await getPublicPostsPreviewBySlug(publicSlug),
timeline: await getPublicTimelinePreviewBySlug(publicSlug),
rssItems: await getPublicRssPreviewBySlug(publicSlug),
modules: hub.modules,
posts: hub.posts,
timeline: hub.timeline,
rssItems: hub.rssItems,
};
if (owner.bioVisibility === "public" && owner.bioMarkdown) {

229
server/api/public/profile/[publicSlug]/consistency.test.ts

@ -0,0 +1,229 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
const ownerRecord = {
id: 310001,
publicSlug: "consistency-user",
nickname: "Consistency",
avatar: null,
avatarVisibility: "public",
bioVisibility: "public",
bioMarkdown: "",
socialLinksJson: "[]",
status: "active",
};
const selectMock = mock(() => ({
from: () => ({
where: () => ({
limit: async () => [ownerRecord],
}),
}),
}));
const parseSocialLinksJson = mock(() => []);
const normalizePublicListPage = mock((pageRaw: unknown) => {
const n = Number(pageRaw);
return Number.isFinite(n) && n >= 1 ? Math.trunc(n) : 1;
});
function publicTotal<T extends { visibility: string }>(rows: T[]) {
return rows.filter((row) => row.visibility === "public").length;
}
const previewPosts = [
{ slug: "preview-post-a", visibility: "public" },
{ slug: "preview-post-b", visibility: "public" },
{ slug: "preview-post-hidden", visibility: "private" },
];
const previewTimeline = [
{ id: 301, visibility: "public" },
{ id: 302, visibility: "public" },
{ id: 303, visibility: "private" },
];
const previewReading = [
{ id: 401, visibility: "public" },
{ id: 402, visibility: "public" },
{ id: 403, visibility: "unlisted" },
];
const pagePosts = [
{ slug: "page-post-1", visibility: "public" },
{ slug: "page-post-2", visibility: "private" },
{ slug: "page-post-3", visibility: "public" },
];
const pageTimeline = [
{ id: 501, visibility: "private" },
{ id: 502, visibility: "public" },
{ id: 503, visibility: "public" },
];
const pageReading = [
{ id: 601, visibility: "public" },
{ id: 602, visibility: "unlisted" },
{ id: 603, visibility: "public" },
];
const getPublicPostsPreviewBySlug = mock(async () => ({
items: previewPosts.filter((row) => row.visibility === "public"),
total: publicTotal(previewPosts),
}));
const getPublicTimelinePreviewBySlug = mock(async () => ({
items: previewTimeline.filter((row) => row.visibility === "public"),
total: publicTotal(previewTimeline),
}));
const getPublicRssPreviewBySlug = mock(async () => ({
items: previewReading.filter((row) => row.visibility === "public"),
total: publicTotal(previewReading),
}));
const getPublicPostsPageBySlug = mock(async () => ({
items: pagePosts.filter((row) => row.visibility === "public"),
total: publicTotal(pagePosts),
page: 1,
pageSize: 10,
}));
const getPublicTimelinePageBySlug = mock(async () => ({
items: pageTimeline.filter((row) => row.visibility === "public"),
total: publicTotal(pageTimeline),
page: 1,
pageSize: 10,
}));
const getPublicRssPageBySlug = mock(async () => ({
items: pageReading.filter((row) => row.visibility === "public"),
total: publicTotal(pageReading),
page: 1,
pageSize: 10,
}));
mock.module("drizzle-pkg/lib/db", () => ({
dbGlobal: {
select: selectMock,
},
}));
mock.module("drizzle-pkg/lib/schema/auth", () => ({
users: {
id: "id",
publicSlug: "publicSlug",
status: "status",
},
}));
mock.module("drizzle-orm", () => ({
and: (...conditions: unknown[]) => conditions,
eq: (a: unknown, b: unknown) => [a, b],
}));
mock.module("#server/service/profile", () => ({
parseSocialLinksJson,
}));
let realGetPublicHubBySlug: ((...args: unknown[]) => unknown) | undefined;
mock.module("#server/service/public-hub", () => ({
getPublicHubBySlug: (...args: unknown[]) => {
if (!realGetPublicHubBySlug) {
throw new Error("realGetPublicHubBySlug not initialized");
}
return realGetPublicHubBySlug(...args);
},
}));
mock.module("#server/service/posts", () => ({
getPublicPostsPreviewBySlug,
getPublicPostsPageBySlug,
}));
mock.module("#server/service/timeline", () => ({
getPublicTimelinePreviewBySlug,
getPublicTimelinePageBySlug,
}));
mock.module("#server/service/rss", () => ({
getPublicRssPreviewBySlug,
getPublicRssPageBySlug,
}));
({ getPublicHubBySlug: realGetPublicHubBySlug } = await import("../../../../service/public-hub"));
mock.module("#server/utils/public-pagination", () => ({
normalizePublicListPage,
}));
mock.module("h3", () => ({
defineEventHandler: (handler: unknown) => handler,
createError: (input: { statusCode: number; statusMessage: string }) => {
const error = new Error(input.statusMessage) as Error & {
statusCode: number;
statusMessage: string;
};
error.statusCode = input.statusCode;
error.statusMessage = input.statusMessage;
return error;
},
getQuery: (event: { query?: Record<string, unknown> }) => event.query ?? {},
}));
globalThis.defineEventHandler ??= ((handler: unknown) => handler) as never;
globalThis.createError ??= ((input: { statusCode: number; statusMessage: string }) => {
const error = new Error(input.statusMessage) as Error & {
statusCode: number;
statusMessage: string;
};
error.statusCode = input.statusCode;
error.statusMessage = input.statusMessage;
return error;
}) as never;
globalThis.getQuery ??= ((event: { query?: Record<string, unknown> }) => event.query ?? {}) as never;
globalThis.R ??= {
success: (data: unknown) => data,
};
const profileHandler = (await import("../[publicSlug].get")).default;
const postsHandler = (await import("./posts/index.get")).default;
const timelineHandler = (await import("./timeline/index.get")).default;
const readingHandler = (await import("./reading/index.get")).default;
describe("public profile totals consistency", () => {
beforeEach(() => {
selectMock.mockClear();
parseSocialLinksJson.mockClear();
getPublicPostsPreviewBySlug.mockClear();
getPublicTimelinePreviewBySlug.mockClear();
getPublicRssPreviewBySlug.mockClear();
getPublicPostsPageBySlug.mockClear();
getPublicTimelinePageBySlug.mockClear();
getPublicRssPageBySlug.mockClear();
normalizePublicListPage.mockClear();
});
test("同一 slug 下 hub.modules.<x>.total 与对应子页 total 一致(public-only)", async () => {
const [profileRes, postsRes, timelineRes, readingRes] = await Promise.all([
profileHandler({ context: { params: { publicSlug: ownerRecord.publicSlug } } } as never),
postsHandler({
context: { params: { publicSlug: ownerRecord.publicSlug } },
query: { page: "1" },
} as never),
timelineHandler({
context: { params: { publicSlug: ownerRecord.publicSlug } },
query: { page: "1" },
} as never),
readingHandler({
context: { params: { publicSlug: ownerRecord.publicSlug } },
query: { page: "1" },
} as never),
]);
expect(profileRes.modules.posts.total).toBe(postsRes.total);
expect(profileRes.modules.timeline.total).toBe(timelineRes.total);
expect(profileRes.modules.reading.total).toBe(readingRes.total);
expect(profileRes.modules.posts.total).toBe(profileRes.posts.total);
expect(profileRes.modules.timeline.total).toBe(profileRes.timeline.total);
expect(profileRes.modules.reading.total).toBe(profileRes.rssItems.total);
expect(postsRes.total).toBe(2);
expect(timelineRes.total).toBe(2);
expect(readingRes.total).toBe(2);
});
});

168
server/service/public-hub/index.test.ts

@ -0,0 +1,168 @@
import { describe, expect, mock, test } from "bun:test";
const defaultPostsPreview = {
items: [
{ slug: "p1", title: "post-1" },
{ slug: "p2", title: "post-2" },
{ slug: "p3", title: "post-3" },
],
total: 3,
};
const defaultTimelinePreview = {
items: [
{ id: 1, title: "timeline-1" },
{ id: 2, title: "timeline-2" },
{ id: 3, title: "timeline-3" },
],
total: 9,
};
const defaultRssPreview = {
items: [
{ id: 10, title: "rss-1" },
{ id: 11, title: "rss-2" },
{ id: 12, title: "rss-3" },
],
total: 12,
};
const getPublicPostsPreviewBySlug = mock(async () => defaultPostsPreview);
const getPublicTimelinePreviewBySlug = mock(async () => defaultTimelinePreview);
const getPublicRssPreviewBySlug = mock(async () => defaultRssPreview);
const unusedPageMock = async () => {
throw new Error("page service should not be called in public-hub tests");
};
mock.module("#server/service/posts", () => ({
getPublicPostsPreviewBySlug,
getPublicPostsPageBySlug: unusedPageMock,
}));
mock.module("#server/service/timeline", () => ({
getPublicTimelinePreviewBySlug,
getPublicTimelinePageBySlug: unusedPageMock,
}));
mock.module("#server/service/rss", () => ({
getPublicRssPreviewBySlug,
getPublicRssPageBySlug: unusedPageMock,
}));
const { getPublicHubBySlug } = await import("./index");
describe("public hub service", () => {
test("items 少于 2 时保持原样", async () => {
getPublicPostsPreviewBySlug.mockResolvedValueOnce({
items: [{ slug: "only-post", title: "post-only" }],
total: 1,
});
getPublicTimelinePreviewBySlug.mockResolvedValueOnce({
items: [{ id: 100, title: "timeline-only" }],
total: 1,
});
getPublicRssPreviewBySlug.mockResolvedValueOnce({
items: [{ id: 200, title: "rss-only" }],
total: 1,
});
const result = await getPublicHubBySlug("short-list-user");
expect(result).toEqual({
modules: {
posts: {
items: [{ slug: "only-post", title: "post-only" }],
total: 1,
},
timeline: {
items: [{ id: 100, title: "timeline-only" }],
total: 1,
},
reading: {
items: [{ id: 200, title: "rss-only" }],
total: 1,
},
},
posts: {
items: [{ slug: "only-post", title: "post-only" }],
total: 1,
},
timeline: {
items: [{ id: 100, title: "timeline-only" }],
total: 1,
},
rssItems: {
items: [{ id: 200, title: "rss-only" }],
total: 1,
},
});
});
test("聚合 posts/timeline/reading 的预览与 total,且预览上限 2", async () => {
const result = await getPublicHubBySlug("demo-user");
expect(getPublicPostsPreviewBySlug).toHaveBeenCalledWith("demo-user");
expect(getPublicTimelinePreviewBySlug).toHaveBeenCalledWith("demo-user");
expect(getPublicRssPreviewBySlug).toHaveBeenCalledWith("demo-user");
expect(result).toEqual({
modules: {
posts: {
items: [
{ slug: "p1", title: "post-1" },
{ slug: "p2", title: "post-2" },
],
total: 3,
},
timeline: {
items: [
{ id: 1, title: "timeline-1" },
{ id: 2, title: "timeline-2" },
],
total: 9,
},
reading: {
items: [
{ id: 10, title: "rss-1" },
{ id: 11, title: "rss-2" },
],
total: 12,
},
},
posts: {
items: [
{ slug: "p1", title: "post-1" },
{ slug: "p2", title: "post-2" },
{ slug: "p3", title: "post-3" },
],
total: 3,
},
timeline: {
items: [
{ id: 1, title: "timeline-1" },
{ id: 2, title: "timeline-2" },
{ id: 3, title: "timeline-3" },
],
total: 9,
},
rssItems: {
items: [
{ id: 10, title: "rss-1" },
{ id: 11, title: "rss-2" },
{ id: 12, title: "rss-3" },
],
total: 12,
},
});
});
test("任一依赖 reject 时函数整体 reject", async () => {
getPublicTimelinePreviewBySlug.mockRejectedValueOnce(
new Error("timeline service failed"),
);
await expect(getPublicHubBySlug("failing-user")).rejects.toThrow(
"timeline service failed",
);
});
});

52
server/service/public-hub/index.ts

@ -0,0 +1,52 @@
import { getPublicPostsPreviewBySlug } from "#server/service/posts";
import { getPublicTimelinePreviewBySlug } from "#server/service/timeline";
import { getPublicRssPreviewBySlug } from "#server/service/rss";
const PUBLIC_HUB_PREVIEW_LIMIT = 2;
type PublicPostsPreview = Awaited<ReturnType<typeof getPublicPostsPreviewBySlug>>;
type PublicTimelinePreview = Awaited<
ReturnType<typeof getPublicTimelinePreviewBySlug>
>;
type PublicRssPreview = Awaited<ReturnType<typeof getPublicRssPreviewBySlug>>;
export type PublicHubPayload = {
modules: {
posts: PublicPostsPreview;
timeline: PublicTimelinePreview;
reading: PublicRssPreview;
};
posts: PublicPostsPreview;
timeline: PublicTimelinePreview;
rssItems: PublicRssPreview;
};
export async function getPublicHubBySlug(
publicSlug: string,
): Promise<PublicHubPayload> {
const [posts, timeline, reading] = await Promise.all([
getPublicPostsPreviewBySlug(publicSlug),
getPublicTimelinePreviewBySlug(publicSlug),
getPublicRssPreviewBySlug(publicSlug),
]);
return {
modules: {
posts: {
items: posts.items.slice(0, PUBLIC_HUB_PREVIEW_LIMIT),
total: posts.total,
},
timeline: {
items: timeline.items.slice(0, PUBLIC_HUB_PREVIEW_LIMIT),
total: timeline.total,
},
reading: {
items: reading.items.slice(0, PUBLIC_HUB_PREVIEW_LIMIT),
total: reading.total,
},
},
posts,
timeline,
rssItems: reading,
};
}
Loading…
Cancel
Save