Files
nl-blogs/client/src/pages/admin/Posts.vue
2026-06-26 13:37:16 +08:00

416 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="w-full animate-reveal">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-serif italic text-white">文章管理</h1>
<router-link to="/admin/posts/create" class="admin-btn-primary flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
新增文章
</router-link>
</div>
<!-- 搜索和筛选区域 -->
<AdminTableFilter
v-model:keyword="searchQuery"
keyword-placeholder="搜索文章标题或内容..."
@search="handleSearch"
@reset="resetFilters"
>
<template #primaryFilters>
<div class="filter-field">
<label>分类</label>
<CustomSelect v-model="filterCategoryId" :options="categoryOptions" style="width: 130px;" />
</div>
<div class="filter-field">
<label>专栏</label>
<CustomSelect v-model="filterColumnId" :options="columnOptions" style="width: 130px;" />
</div>
</template>
<template #filters>
<div class="filter-field">
<label>状态</label>
<CustomSelect v-model="filterPublished" :options="publishedOptions" style="width: 110px;" />
</div>
<div class="filter-field">
<label>开始日期</label>
<input v-model="startDate" type="date" class="admin-input" style="width: 150px;" />
</div>
<div class="filter-field">
<label>结束日期</label>
<input v-model="endDate" type="date" class="admin-input" style="width: 150px;" />
</div>
</template>
</AdminTableFilter>
<div class="admin-card overflow-hidden">
<div class="overflow-x-auto">
<table class="admin-table">
<thead>
<tr>
<th class="w-20">ID</th>
<th>文章</th>
<th>标签</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="post in posts" :key="post.id" class="group transition-colors duration-200">
<td class="font-mono text-xs text-white/40 align-top pt-4">#{{ post.id }}</td>
<td class="align-top py-3">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-white group-hover:text-art-accent transition-colors">{{ post.title }}</span>
<button
type="button"
@click="handleToggleStatus(post)"
:class="['px-2 py-0.5 rounded-full text-xs font-medium border transition-all hover:opacity-80 shrink-0', post.isPublished === 1 ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20']"
title="点击切换状态"
>
{{ post.isPublished === 1 ? '已发布' : '草稿' }}
</button>
<button
type="button"
@click="openAccessLogModal(post)"
class="admin-btn-secondary py-0.5 px-2 text-xs shrink-0"
title="查看访问记录"
>
查看
</button>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-art-muted mt-1.5">
<button
type="button"
class="hover:text-white transition-colors"
@click="openRelationModal(post, 'category')"
>
{{ post.categoryName || post.category?.name || '未分类' }}
</button>
<span class="text-white/20">·</span>
<button
v-if="post.columnName"
type="button"
class="text-art-accent/80 hover:text-art-accent transition-colors"
@click="openRelationModal(post, 'column')"
>
{{ post.columnName }}
</button>
<button
v-else
type="button"
class="text-art-muted/60 hover:text-art-muted transition-colors"
@click="openRelationModal(post, 'column')"
>
无专栏
</button>
<span class="text-white/20">·</span>
<AuthorInfo
:name="post.userName"
:email="post.userEmail"
:avatar="post.userAvatar"
:user-id="post.userId"
variant="compact"
size="sm"
/>
<span class="text-white/20">·</span>
<span>{{ post.date }}</span>
</div>
</td>
<td class="align-top py-3">
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-1">
<button
v-for="tag in post.tags.slice(0, 3)"
:key="tag.id"
type="button"
class="px-2 py-0.5 rounded bg-white/5 border border-white/5 text-xs text-art-muted cursor-pointer hover:border-art-accent/40 hover:text-white transition-colors"
@click="openRelationModal(post, 'tags')"
>
#{{ tag.name }}
</button>
<span v-if="post.tags.length > 3" class="text-art-muted text-xs self-center">+{{ post.tags.length - 3 }}</span>
</div>
<button
v-else
type="button"
class="px-2 py-0.5 rounded border border-dashed border-white/20 text-xs text-art-muted/60 cursor-pointer hover:border-art-accent/40 hover:text-art-muted transition-colors"
@click="openRelationModal(post, 'tags')"
>
无标签
</button>
</td>
<td class="text-right align-top py-3">
<div class="flex items-center justify-end gap-2 flex-wrap">
<button @click="openRelationModal(post)" class="admin-btn-secondary py-1 px-3 text-xs">
管理
</button>
<button @click="openHistoryModal(post)" class="admin-btn-secondary py-1 px-3 text-xs">
历史
</button>
<router-link :to="`/admin/posts/${post.id}/edit`" class="admin-btn-secondary py-1 px-3 text-xs">
编辑
</router-link>
<button @click="deletePost(post.id)" class="admin-btn-danger py-1 px-3 text-xs">
删除
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="posts.length === 0 && !loading" class="p-16 text-center">
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl">📝</div>
<h3 class="text-white font-medium mb-2">暂无文章</h3>
<p class="text-art-muted text-sm mb-6">{{ searchQuery ? '没有找到匹配的文章' : '开始您的创作之旅吧' }}</p>
<router-link v-if="!searchQuery" to="/admin/posts/create" class="admin-btn-secondary inline-flex items-center gap-2">
+ 新增文章
</router-link>
</div>
<!-- 分页组件 -->
<div v-if="total > 0" class="border-t border-white/10 px-6 py-4 flex items-center justify-between">
<div class="text-sm text-art-muted">
{{ total }} {{ currentPage }} / {{ totalPages }}
</div>
<div class="flex items-center gap-2">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage <= 1"
class="admin-btn-secondary px-3 py-1 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
上一页
</button>
<div class="flex items-center gap-1">
<button
v-for="page in visiblePages"
:key="page"
@click="goToPage(page)"
:class="[
'px-3 py-1 text-sm rounded transition-colors',
page === currentPage
? 'bg-art-accent text-white'
: 'admin-btn-secondary'
]"
>
{{ page }}
</button>
</div>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="admin-btn-secondary px-3 py-1 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
下一页
</button>
</div>
</div>
</div>
<!-- Post Relation Modal -->
<PostRelationModal
v-model:isOpen="isRelationModalOpen"
:post="editingPost"
:focus-section="relationFocusSection"
@saved="fetchPosts"
/>
<PostHistoryModal
:is-open="isHistoryModalOpen"
:post-id="historyPostId"
@close="isHistoryModalOpen = false"
@restored="fetchPosts"
/>
<!-- Access Log Modal -->
<AccessLogModal
v-model:isOpen="isAccessLogModalOpen"
:postId="selectedPostId"
:postTitle="selectedPostTitle"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { getAdminPosts, deletePost as deletePostApi, Post, togglePostStatus, getAdminCategories, getAdminColumns } from '../../services/api'
import { useToast } from '../../composables/useToast'
import PostRelationModal from '../../components/admin/PostRelationModal.vue'
import PostHistoryModal from '../../components/admin/PostHistoryModal.vue'
import AccessLogModal from '../../components/admin/AccessLogModal.vue'
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
import CustomSelect from '../../components/CustomSelect.vue'
import AuthorInfo from '../../components/AuthorInfo.vue'
type RelationFocusSection = 'category' | 'column' | 'tags' | 'all'
const toast = useToast()
const posts = ref<Post[]>([])
const isRelationModalOpen = ref(false)
const editingPost = ref<Post | null>(null)
const relationFocusSection = ref<RelationFocusSection>('all')
const isHistoryModalOpen = ref(false)
const historyPostId = ref<number | string>('')
const isAccessLogModalOpen = ref(false)
const selectedPostId = ref<number | null>(null)
const selectedPostTitle = ref<string>('')
// 搜索和分页状态
const searchQuery = ref('')
const filterCategoryId = ref<number | string>(0)
const filterColumnId = ref<number | string>(0)
const filterPublished = ref<number | string>('')
const startDate = ref('')
const endDate = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const loading = ref(false)
const categories = ref<{ id: number; name: string }[]>([])
const columns = ref<{ id: number; name: string }[]>([])
const categoryOptions = computed(() => [
{ value: 0, label: '全部分类' },
...categories.value.map(c => ({ value: c.id, label: c.name }))
])
const columnOptions = computed(() => [
{ value: 0, label: '全部专栏' },
...columns.value.map(c => ({ value: c.id, label: c.name }))
])
const publishedOptions = [
{ value: '', label: '全部状态' },
{ value: 1, label: '已发布' },
{ value: 0, label: '草稿' },
]
// 计算总页数
const totalPages = computed(() => {
return Math.ceil(total.value / pageSize.value)
})
// 计算可见的页码
const visiblePages = computed(() => {
const pages: number[] = []
const maxVisible = 5
let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
let end = Math.min(totalPages.value, start + maxVisible - 1)
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1)
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
const openRelationModal = (post: Post, section: RelationFocusSection = 'all') => {
editingPost.value = post
relationFocusSection.value = section
isRelationModalOpen.value = true
}
const openHistoryModal = (post: Post) => {
historyPostId.value = post.id
isHistoryModalOpen.value = true
}
const openAccessLogModal = (post: Post) => {
selectedPostId.value = post.id
selectedPostTitle.value = post.title
isAccessLogModalOpen.value = true
}
const fetchPosts = async () => {
loading.value = true
try {
const params: Parameters<typeof getAdminPosts>[2] = {
keyword: searchQuery.value.trim() || undefined,
startDate: startDate.value || undefined,
endDate: endDate.value || undefined,
}
if (filterCategoryId.value) params.categoryId = Number(filterCategoryId.value)
if (filterColumnId.value) params.columnId = Number(filterColumnId.value)
if (filterPublished.value !== '') params.isPublished = Number(filterPublished.value)
const response = await getAdminPosts(currentPage.value, pageSize.value, params)
if ('list' in response) {
posts.value = response.list
total.value = response.total
} else {
posts.value = response as any
total.value = (response as any).length || 0
}
} catch (error) {
console.error('Error fetching posts:', error)
toast.showToast('获取文章列表失败', 'error')
} finally {
loading.value = false
}
}
const handleSearch = () => {
currentPage.value = 1
fetchPosts()
}
const resetFilters = () => {
searchQuery.value = ''
filterCategoryId.value = 0
filterColumnId.value = 0
filterPublished.value = ''
startDate.value = ''
endDate.value = ''
currentPage.value = 1
fetchPosts()
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
fetchPosts()
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
const handleToggleStatus = async (post: Post) => {
const newStatus = post.isPublished === 1 ? 0 : 1
try {
await togglePostStatus(post.id, newStatus)
post.isPublished = newStatus
toast.success(newStatus === 1 ? '文章已发布' : '文章已转为草稿')
} catch (error) {
console.error('Error toggling status:', error)
toast.error('更新状态失败')
}
}
const deletePost = async (id: number) => {
if (confirm('确定要删除这篇文章吗?')) {
try {
await deletePostApi(id)
toast.showToast('文章删除成功', 'success')
fetchPosts()
} catch (error) {
console.error('Error deleting post:', error)
toast.showToast('删除文章失败', 'error')
}
}
}
onMounted(async () => {
try {
const [cats, cols] = await Promise.all([getAdminCategories(), getAdminColumns()])
categories.value = cats
columns.value = cols
} catch { /* ignore */ }
fetchPosts()
})
</script>
<style scoped>
/* Scoped styles removed, using Tailwind classes */
</style>