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.
 
 
 
 
 

478 lines
16 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
tags?: string[]
}
type TagMode = 'or' | 'and'
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
}
type PostListPayload = ListPayload<PostItem> & {
availableTags?: string[]
}
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 postSelectedTags = ref<string[]>([])
const postTagMode = ref<TagMode>('or')
const postTagInput = ref('')
const { data: postsData, pending: postsPending } = await useAsyncData(
() => `reader-posts-${props.slug}-${postsPage.value}-${postSelectedTags.value.join(',')}-${postTagMode.value}`,
async () => {
const base = `/api/public/profile/${encodeURIComponent(props.slug)}/posts`
const query = new URLSearchParams()
if (postsPage.value > 1) {
query.set('page', String(postsPage.value))
}
if (postSelectedTags.value.length) {
query.set('tags', postSelectedTags.value.join(','))
}
query.set('tagMode', postTagMode.value)
const url = query.toString() ? `${base}?${query.toString()}` : base
const res = await $fetch<ApiResponse<PostListPayload>>(url)
return unwrapApiBody(res)
},
{ watch: [() => props.slug, postsPage, postSelectedTags, postTagMode] },
)
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
postSelectedTags.value = []
postTagMode.value = 'or'
activeSection.value = 'posts'
})
function updatePostFilter(next: { tags?: string[]; mode?: TagMode } = {}) {
postSelectedTags.value = next.tags ?? postSelectedTags.value
postTagMode.value = next.mode ?? postTagMode.value
postsPage.value = 1
}
const postQuickTags = computed(() => {
const byApi = postsData.value?.availableTags ?? []
const fromItems = (postsData.value?.items ?? [])
.flatMap(item => item.tags ?? [])
.filter(Boolean)
return [...new Set([...byApi, ...fromItems])]
})
const postQuickTagsUnselected = computed(() =>
postQuickTags.value.filter(tag => !postSelectedTags.value.includes(tag)),
)
function addPostTag(raw: string) {
const tag = raw.trim().replace(/\s+/g, ' ')
if (!tag) {
return
}
if (postSelectedTags.value.includes(tag)) {
postTagInput.value = ''
return
}
updatePostFilter({ tags: [...postSelectedTags.value, tag] })
postTagInput.value = ''
}
function removePostTag(tag: string) {
updatePostFilter({ tags: postSelectedTags.value.filter(t => t !== tag) })
}
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 class="rounded-xl border border-default/70 bg-elevated/15 px-3 py-2.5 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<UInput
v-model="postTagInput"
size="xs"
icon="i-lucide-tag"
placeholder="标签回车"
class="min-w-0 flex-1"
@keydown.enter.prevent="addPostTag(postTagInput)"
/>
<div class="flex items-center gap-1">
<UButton
size="xs"
color="neutral"
:variant="postTagMode === 'or' ? 'soft' : 'ghost'"
@click="updatePostFilter({ mode: 'or' })"
>
OR
</UButton>
<UButton
size="xs"
color="neutral"
:variant="postTagMode === 'and' ? 'soft' : 'ghost'"
@click="updatePostFilter({ mode: 'and' })"
>
AND
</UButton>
<UButton
size="xs"
color="neutral"
variant="ghost"
@click="updatePostFilter({ tags: [], mode: 'or' })"
>
清空
</UButton>
</div>
</div>
<div v-if="postSelectedTags.length" class="flex flex-wrap gap-1.5">
<UBadge
v-for="tag in postSelectedTags"
:key="tag"
size="xs"
color="neutral"
variant="subtle"
class="cursor-default pr-1"
>
<span>{{ tag }}</span>
<button
type="button"
class="ml-1 rounded p-0.5 text-muted hover:text-default"
aria-label="移除标签"
@click="removePostTag(tag)"
>
<UIcon name="i-lucide-x" class="size-3" />
</button>
</UBadge>
</div>
<div v-if="postQuickTagsUnselected.length" class="max-h-28 overflow-y-auto pr-1 flex flex-wrap gap-1.5">
<UButton
v-for="tag in postQuickTagsUnselected"
:key="tag"
size="xs"
color="neutral"
variant="ghost"
class="font-normal"
@click="addPostTag(tag)"
>
+ {{ tag }}
</UButton>
</div>
</div>
<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>
<PostTagsPostTagBadges v-if="p.tags?.length" :tags="p.tags" :max="4" class="mt-2" />
<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>