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
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>
|
|
|