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.
 
 
 
 
 

352 lines
12 KiB

<script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../utils/http/factory'
import { safeExternalHref } from '../../utils/safe-external-href'
type Section = 'posts' | 'timeline' | 'reading'
type SocialLink = {
label: string
icon?: string
safeHref: string
}
type PostItem = {
title?: string | null
excerpt?: string | null
slug?: string | null
publishedAt?: Date | string | null
}
type TimelineItem = {
id?: number
title?: string | null
occurredOn?: Date | string | null
bodyMarkdown?: string | null
linkUrl?: string | null
}
type ReadingItem = {
title?: string | null
canonicalUrl?: string | null
canonical_url?: string | null
}
type ListPayload<T> = {
items: T[]
total: number
page: number
pageSize: number
}
const props = defineProps<{
slug: string
displayName: string
publicSlug: string | null
avatar: string | null
bioPreviewText: string
hasBioPreview: boolean
socialLinks: SocialLink[]
}>()
const activeSection = ref<Section>('posts')
const postsPage = ref(1)
const timelinePage = ref(1)
const readingPage = ref(1)
const { data: postsData, pending: postsPending } = await useAsyncData(
() => `reader-posts-${props.slug}-${postsPage.value}`,
async () => {
const base = `/api/public/profile/${encodeURIComponent(props.slug)}/posts`
const url = postsPage.value > 1 ? `${base}?page=${postsPage.value}` : base
const res = await $fetch<ApiResponse<ListPayload<PostItem>>>(url)
return unwrapApiBody(res)
},
{ watch: [() => props.slug, postsPage] },
)
const { data: timelineData, pending: timelinePending } = await useAsyncData(
() => `reader-timeline-${props.slug}-${timelinePage.value}`,
async () => {
const base = `/api/public/profile/${encodeURIComponent(props.slug)}/timeline`
const url = timelinePage.value > 1 ? `${base}?page=${timelinePage.value}` : base
const res = await $fetch<ApiResponse<ListPayload<TimelineItem>>>(url)
return unwrapApiBody(res)
},
{ watch: [() => props.slug, timelinePage] },
)
const { data: readingData, pending: readingPending } = await useAsyncData(
() => `reader-reading-${props.slug}-${readingPage.value}`,
async () => {
const base = `/api/public/profile/${encodeURIComponent(props.slug)}/reading`
const url = readingPage.value > 1 ? `${base}?page=${readingPage.value}` : base
const res = await $fetch<ApiResponse<ListPayload<ReadingItem>>>(url)
return unwrapApiBody(res)
},
{ watch: [() => props.slug, readingPage] },
)
const postsTotal = computed(() => postsData.value?.total ?? 0)
const timelineTotal = computed(() => timelineData.value?.total ?? 0)
const readingTotal = computed(() => readingData.value?.total ?? 0)
watch(() => props.slug, () => {
postsPage.value = 1
timelinePage.value = 1
readingPage.value = 1
activeSection.value = 'posts'
})
function postTitle(it: PostItem): string {
const t = it.title
return typeof t === 'string' && t.trim().length ? t : '未命名文章'
}
function postExcerpt(it: PostItem): string {
return typeof it.excerpt === 'string' ? it.excerpt : ''
}
function postSlug(it: PostItem): string {
return typeof it.slug === 'string' ? it.slug : ''
}
function timelineTitle(it: TimelineItem): string {
const t = it.title
return typeof t === 'string' && t.trim().length ? t : '未命名动态'
}
function timelineBody(it: TimelineItem): string {
return typeof it.bodyMarkdown === 'string' ? it.bodyMarkdown : ''
}
function timelineKey(it: TimelineItem, index: number): string | number {
return it.id ?? index
}
function timelineHref(it: TimelineItem): string | undefined {
return safeExternalHref(it.linkUrl)
}
function readingTitle(it: ReadingItem): string {
const t = it.title
return typeof t === 'string' && t.trim().length ? t : '未命名'
}
function readingHref(it: ReadingItem): string | undefined {
return safeExternalHref(it.canonicalUrl ?? it.canonical_url)
}
function readingHost(href: string | undefined): string {
if (!href) {
return ''
}
try {
return new URL(href).hostname
}
catch {
return href
}
}
</script>
<template>
<div class="grid gap-6 lg:grid-cols-[16rem_minmax(0,1fr)] xl:gap-8">
<aside class="space-y-4 lg:sticky lg:top-20 lg:h-fit">
<section class="rounded-2xl border border-default/80 bg-elevated/20 px-4 py-5">
<div class="flex items-center gap-3">
<img
v-if="avatar"
:src="avatar"
alt=""
class="h-14 w-14 rounded-full border border-default object-cover"
>
<div>
<p class="font-semibold text-highlighted">
{{ displayName }}
</p>
<p v-if="publicSlug" class="text-xs text-muted">
@{{ publicSlug }}
</p>
</div>
</div>
<NuxtLink
v-if="hasBioPreview"
:to="`/@${slug}/about`"
class="mt-3 block rounded-lg border border-default/70 bg-default/20 px-3 py-2 text-xs text-muted hover:bg-elevated/40"
>
{{ bioPreviewText }}
</NuxtLink>
<ul v-if="socialLinks.length" class="mt-3 flex flex-wrap gap-2">
<li v-for="(l, i) in socialLinks" :key="i">
<a
:href="l.safeHref"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 text-xs text-muted hover:text-default"
>
<UIcon :name="socialLinkIconName({ ...l, url: l.safeHref })" class="size-3.5" />
{{ l.label }}
</a>
</li>
</ul>
</section>
<nav class="space-y-1 rounded-2xl border border-default/80 bg-elevated/20 p-2" aria-label="阅读区块切换">
<button
type="button"
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors"
:class="activeSection === 'posts' ? 'bg-primary/15 text-highlighted' : 'text-muted hover:bg-elevated/50'"
@click="activeSection = 'posts'"
>
<span>文章</span>
<span class="text-xs tabular-nums">{{ postsTotal }}</span>
</button>
<button
type="button"
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors"
:class="activeSection === 'timeline' ? 'bg-primary/15 text-highlighted' : 'text-muted hover:bg-elevated/50'"
@click="activeSection = 'timeline'"
>
<span>时光机</span>
<span class="text-xs tabular-nums">{{ timelineTotal }}</span>
</button>
<button
type="button"
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors"
:class="activeSection === 'reading' ? 'bg-primary/15 text-highlighted' : 'text-muted hover:bg-elevated/50'"
@click="activeSection = 'reading'"
>
<span>阅读</span>
<span class="text-xs tabular-nums">{{ readingTotal }}</span>
</button>
</nav>
</aside>
<section class="min-w-0">
<div v-if="activeSection === 'posts'" class="space-y-4">
<h2 class="text-lg font-semibold text-highlighted">
文章
</h2>
<div v-if="postsPending" class="text-sm text-muted">
加载中…
</div>
<UEmpty v-else-if="!postsData?.total" title="暂无公开文章" />
<template v-else>
<NuxtLink
v-for="(p, i) in postsData.items"
:key="`${postSlug(p)}-${i}`"
:to="`/@${slug}/posts/${encodeURIComponent(postSlug(p))}`"
class="block rounded-xl border border-default/80 bg-elevated/20 px-4 py-4"
>
<div class="font-semibold text-highlighted">
{{ postTitle(p) }}
</div>
<p v-if="postExcerpt(p)" class="mt-2 text-sm text-muted line-clamp-3">
{{ postExcerpt(p) }}
</p>
</NuxtLink>
<div v-if="postsData.total > 10" class="flex justify-end">
<UPagination
:page="postsPage"
:total="postsData.total"
:items-per-page="10"
size="sm"
@update:page="postsPage = $event"
/>
</div>
</template>
</div>
<div v-else-if="activeSection === 'timeline'" class="space-y-4">
<h2 class="text-lg font-semibold text-highlighted">
时光机
</h2>
<div v-if="timelinePending" class="text-sm text-muted">
加载中…
</div>
<UEmpty v-else-if="!timelineData?.total" title="暂无公开动态" />
<template v-else>
<article
v-for="(e, i) in timelineData.items"
:key="timelineKey(e, i)"
class="rounded-xl border border-default/80 bg-elevated/20 px-4 py-4"
>
<div class="flex items-start justify-between gap-3">
<h3 class="font-semibold text-highlighted">
{{ timelineTitle(e) }}
</h3>
<time
v-if="e.occurredOn"
class="text-xs tabular-nums text-muted"
:datetime="occurredOnToIsoAttr(e.occurredOn)"
>{{ formatOccurredOnDisplay(e.occurredOn) }}</time>
</div>
<p v-if="timelineBody(e)" class="mt-2 whitespace-pre-wrap text-sm text-muted">
{{ timelineBody(e) }}
</p>
<a
v-if="timelineHref(e)"
:href="timelineHref(e)"
target="_blank"
rel="noopener noreferrer"
class="mt-2 inline-flex text-sm text-primary hover:underline"
>
打开链接
</a>
</article>
<div v-if="timelineData.total > 10" class="flex justify-end">
<UPagination
:page="timelinePage"
:total="timelineData.total"
:items-per-page="10"
size="sm"
@update:page="timelinePage = $event"
/>
</div>
</template>
</div>
<div v-else class="space-y-4">
<h2 class="text-lg font-semibold text-highlighted">
阅读
</h2>
<div v-if="readingPending" class="text-sm text-muted">
加载中…
</div>
<UEmpty v-else-if="!readingData?.total" title="暂无公开阅读条目" />
<template v-else>
<template v-for="(it, i) in readingData.items" :key="i">
<a
v-if="readingHref(it)"
:href="readingHref(it)"
target="_blank"
rel="noopener noreferrer"
class="block rounded-xl border border-default/80 bg-elevated/20 px-4 py-4"
>
<div class="font-semibold text-highlighted">
{{ readingTitle(it) }}
</div>
<div class="mt-2 text-sm text-muted">
{{ readingHost(readingHref(it)) }}
</div>
</a>
<div v-else class="rounded-xl border border-default/80 bg-elevated/20 px-4 py-4">
<div class="font-semibold text-highlighted">
{{ readingTitle(it) }}
</div>
</div>
</template>
<div v-if="readingData.total > 10" class="flex justify-end">
<UPagination
:page="readingPage"
:total="readingData.total"
:items-per-page="10"
size="sm"
@update:page="readingPage = $event"
/>
</div>
</template>
</div>
</section>
</div>
</template>