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.
 
 
 
 
 

306 lines
8.5 KiB

<script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession'
usePageTitle('我的文章')
type Visibility = 'private' | 'unlisted' | 'public'
type Row = { id: number; title: string; slug: string; visibility: Visibility; tags?: string[] }
type ViewMode = 'list' | 'card'
type TagMode = 'or' | 'and'
type Payload = {
items: Row[]
total: number
page: number
pageSize: number
availableTags?: string[]
}
const posts = ref<Row[]>([])
const loading = ref(true)
const viewMode = ref<ViewMode>('card')
const { user } = useAuthSession()
const { fetchData } = useClientApi()
const route = useRoute()
const router = useRouter()
const page = ref(1)
const total = ref(0)
const pageSize = ref(20)
const availableTagsApi = ref<string[]>([])
const selectedTags = ref<string[]>([])
const tagMode = ref<TagMode>('or')
function parsePage(raw: unknown): number {
const n =
typeof raw === 'string'
? Number.parseInt(raw, 10)
: typeof raw === 'number'
? raw
: Number.NaN
if (!Number.isFinite(n) || n < 1) {
return 1
}
return Math.floor(n)
}
async function load() {
loading.value = true
try {
const query = new URLSearchParams()
if (page.value > 1) {
query.set('page', String(page.value))
}
if (selectedTags.value.length) {
query.set('tags', selectedTags.value.join(','))
}
query.set('tagMode', tagMode.value)
const url = query.toString() ? `/api/me/posts?${query.toString()}` : '/api/me/posts'
const data = await fetchData<Payload>(url)
posts.value = data.items
total.value = data.total
pageSize.value = data.pageSize
availableTagsApi.value = data.availableTags ?? []
} finally {
loading.value = false
}
}
const availableTags = computed(() => {
const byApi = availableTagsApi.value ?? []
const byItems = posts.value.flatMap(item => item.tags ?? []).filter(Boolean)
return [...new Set([...byApi, ...byItems])]
})
function onPageChange(nextPage: number) {
page.value = nextPage
const query = { ...route.query }
if (nextPage > 1) {
query.page = String(nextPage)
} else {
delete query.page
}
void router.replace({ query })
}
watch(
() => [route.query.page, route.query.tags, route.query.tagMode] as const,
() => {
const next = parsePage(route.query.page)
page.value = next
selectedTags.value =
typeof route.query.tags === 'string'
? route.query.tags.split(',').map(x => x.trim()).filter(Boolean)
: []
tagMode.value = route.query.tagMode === 'and' ? 'and' : 'or'
void load()
},
)
onMounted(() => {
page.value = parsePage(route.query.page)
selectedTags.value =
typeof route.query.tags === 'string'
? route.query.tags.split(',').map(x => x.trim()).filter(Boolean)
: []
tagMode.value = route.query.tagMode === 'and' ? 'and' : 'or'
void load()
})
const tagModeItems = [
{ label: '任一命中 (OR)', value: 'or' },
{ label: '全部命中 (AND)', value: 'and' },
]
function updateRouteFilters(nextPage = 1) {
const query = { ...route.query } as Record<string, string>
if (selectedTags.value.length) {
query.tags = selectedTags.value.join(',')
} else {
delete query.tags
}
query.tagMode = tagMode.value
if (nextPage > 1) {
query.page = String(nextPage)
} else {
delete query.page
}
void router.replace({ query })
}
function onTagsChange(next: string[]) {
selectedTags.value = next
updateRouteFilters(1)
}
function clearFilters() {
selectedTags.value = []
tagMode.value = 'or'
updateRouteFilters(1)
}
function postDetailHref(post: Row) {
if (post.visibility !== 'public') {
return `/me/posts/preview/${post.id}`
}
const ps = user.value?.publicSlug
if (!ps || !post.slug) {
return ''
}
return `/@${ps}/posts/${encodeURIComponent(post.slug)}`
}
function visibilityLabel(visibility: string) {
if (visibility === 'public') {
return '公开'
}
if (visibility === 'unlisted') {
return '仅链接'
}
return '私密'
}
</script>
<template>
<UContainer class="py-8 space-y-4 max-w-6xl">
<div class="flex flex-wrap justify-between items-center gap-3">
<h1 class="text-2xl font-semibold">
文章
</h1>
<div class="flex items-center gap-2">
<div class="flex items-center rounded-lg border border-default p-0.5 bg-elevated/40">
<UButton
size="sm"
:variant="viewMode === 'list' ? 'soft' : 'ghost'"
color="neutral"
icon="i-lucide-list"
:class="viewMode === 'list' ? 'shadow-sm' : 'text-muted'"
@click="viewMode = 'list'"
>
列表
</UButton>
<UButton
size="sm"
:variant="viewMode === 'card' ? 'soft' : 'ghost'"
color="neutral"
icon="i-lucide-layout-grid"
:class="viewMode === 'card' ? 'shadow-sm' : 'text-muted'"
@click="viewMode = 'card'"
>
卡片
</UButton>
</div>
<UButton to="/me/posts/new">
新建
</UButton>
</div>
</div>
<UCard :ui="{ body: 'p-4 space-y-3' }">
<div class="flex items-center justify-between gap-2">
<div class="text-sm text-muted">
标签筛选
</div>
<UButton size="xs" color="neutral" variant="ghost" @click="clearFilters">
清空
</UButton>
</div>
<PostTagsInput
:model-value="selectedTags"
:suggestions="availableTags"
placeholder="输入标签回车筛选"
@update:model-value="onTagsChange"
/>
<USelect
v-model="tagMode"
:items="tagModeItems"
class="w-full sm:w-56"
@update:model-value="() => updateRouteFilters(1)"
/>
</UCard>
<div v-if="loading" class="text-muted">
加载中…
</div>
<UEmpty
v-else-if="!posts.length"
:title="selectedTags.length ? '没有匹配结果' : '暂无文章'"
:description="selectedTags.length ? '试试调整标签筛选。' : '创建第一篇 Markdown 文章'"
/>
<ul v-else-if="viewMode === 'list'" class="space-y-2">
<li
v-for="p in posts"
:key="p.id"
class="flex flex-wrap justify-between items-center gap-3 border border-default rounded-lg p-3"
>
<div class="min-w-0 flex-1">
<div class="font-medium truncate">
{{ p.title }}
</div>
<div class="text-xs text-muted">
/{{ p.slug }} · {{ visibilityLabel(p.visibility) }}
</div>
<PostTagsPostTagBadges v-if="p.tags?.length" :tags="p.tags" class="mt-1.5" />
</div>
<div class="flex flex-wrap gap-1 justify-end">
<UButton
v-if="postDetailHref(p)"
:to="postDetailHref(p)"
size="xs"
variant="soft"
color="neutral"
>
详情
</UButton>
<UButton :to="`/me/posts/${p.id}`" size="xs" variant="ghost">
编辑
</UButton>
</div>
</li>
</ul>
<div v-else class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<UCard
v-for="p in posts"
:key="p.id"
:ui="{ body: 'p-4 space-y-3', footer: 'px-4 py-3 border-t border-default' }"
>
<div class="space-y-2">
<div class="font-medium line-clamp-2 min-h-12">
{{ p.title }}
</div>
<div class="text-xs text-muted break-all">
/{{ p.slug }}
</div>
<div class="flex flex-wrap items-center gap-2">
<UBadge size="sm" color="neutral" variant="soft">
{{ visibilityLabel(p.visibility) }}
</UBadge>
</div>
<PostTagsPostTagBadges v-if="p.tags?.length" :tags="p.tags" :max="4" class="pt-1" />
</div>
<template #footer>
<div class="flex flex-wrap gap-2 justify-end">
<UButton
v-if="postDetailHref(p)"
:to="postDetailHref(p)"
size="xs"
variant="soft"
color="neutral"
>
详情
</UButton>
<UButton :to="`/me/posts/${p.id}`" size="xs" variant="ghost">
编辑
</UButton>
</div>
</template>
</UCard>
</div>
<div v-if="!loading && total > pageSize" class="flex justify-end">
<UPagination
:page="page"
:total="total"
:items-per-page="pageSize"
size="sm"
@update:page="onPageChange"
/>
</div>
</UContainer>
</template>