You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
260 lines
8.2 KiB
260 lines
8.2 KiB
<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>
|
|
|