@ -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.l inks.length"
v - if = "socialL inks.length"
class = "flex flex-wrap items-center justify-center gap-2.5"
aria - label = "社交链接"
>
< li v-for ="(l, i) in data.l inks" :key ="i" >
< li v-for ="(l, i) in socialL inks" :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 >