Files
nl-blogs/client/src/pages/admin/Posts.vue

416 lines
15 KiB
Vue
Raw Normal View History

2026-01-15 13:51:44 +08:00
<template>
2026-01-16 11:06:59 +08:00
<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>
新增文章
2026-01-15 13:51:44 +08:00
</router-link>
</div>
2026-01-23 16:05:23 +08:00
<!-- 搜索和筛选区域 -->
2026-06-25 17:57:51 +08:00
<AdminTableFilter
v-model:keyword="searchQuery"
keyword-placeholder="搜索文章标题或内容..."
@search="handleSearch"
@reset="resetFilters"
>
2026-06-26 13:37:16 +08:00
<template #primaryFilters>
2026-06-25 17:57:51 +08:00
<div class="filter-field">
<label>分类</label>
<CustomSelect v-model="filterCategoryId" :options="categoryOptions" style="width: 130px;" />
2026-01-23 16:05:23 +08:00
</div>
2026-06-25 17:57:51 +08:00
<div class="filter-field">
<label>专栏</label>
<CustomSelect v-model="filterColumnId" :options="columnOptions" style="width: 130px;" />
</div>
2026-06-26 13:37:16 +08:00
</template>
<template #filters>
2026-06-25 17:57:51 +08:00
<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>
2026-01-23 16:05:23 +08:00
2026-01-16 11:06:59 +08:00
<div class="admin-card overflow-hidden">
<div class="overflow-x-auto">
<table class="admin-table">
<thead>
<tr>
<th class="w-20">ID</th>
2026-06-26 13:37:16 +08:00
<th>文章</th>
2026-01-19 16:14:08 +08:00
<th>标签</th>
2026-01-16 11:06:59 +08:00
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="post in posts" :key="post.id" class="group transition-colors duration-200">
2026-06-26 13:37:16 +08:00
<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>
2026-01-19 16:14:08 +08:00
</td>
2026-06-26 13:37:16 +08:00
<td class="align-top py-3">
2026-01-19 16:14:08 +08:00
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-1">
2026-06-25 16:57:06 +08:00
<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')"
2026-01-19 16:14:08 +08:00
>
#{{ tag.name }}
2026-06-25 16:57:06 +08:00
</button>
<span v-if="post.tags.length > 3" class="text-art-muted text-xs self-center">+{{ post.tags.length - 3 }}</span>
2026-01-19 16:14:08 +08:00
</div>
2026-06-25 16:57:06 +08:00
<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>
2026-01-19 16:14:08 +08:00
</td>
2026-06-26 13:37:16 +08:00
<td class="text-right align-top py-3">
2026-06-25 16:57:06 +08:00
<div class="flex items-center justify-end gap-2 flex-wrap">
2026-01-19 16:14:08 +08:00
<button @click="openRelationModal(post)" class="admin-btn-secondary py-1 px-3 text-xs">
管理
</button>
2026-06-25 16:57:06 +08:00
<button @click="openHistoryModal(post)" class="admin-btn-secondary py-1 px-3 text-xs">
历史
</button>
2026-01-16 11:06:59 +08:00
<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>
2026-01-23 16:05:23 +08:00
<div v-if="posts.length === 0 && !loading" class="p-16 text-center">
2026-01-16 11:06:59 +08:00
<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>
2026-01-23 16:05:23 +08:00
<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">
2026-01-16 11:06:59 +08:00
+ 新增文章
</router-link>
</div>
2026-01-23 16:05:23 +08:00
<!-- 分页组件 -->
<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>
2026-01-15 13:51:44 +08:00
</div>
2026-01-16 17:03:34 +08:00
2026-01-19 16:14:08 +08:00
<!-- Post Relation Modal -->
<PostRelationModal
v-model:isOpen="isRelationModalOpen"
:post="editingPost"
2026-06-25 16:57:06 +08:00
:focus-section="relationFocusSection"
2026-01-19 16:14:08 +08:00
@saved="fetchPosts"
/>
2026-01-20 11:00:42 +08:00
2026-06-25 16:57:06 +08:00
<PostHistoryModal
:is-open="isHistoryModalOpen"
:post-id="historyPostId"
@close="isHistoryModalOpen = false"
@restored="fetchPosts"
/>
2026-01-20 11:00:42 +08:00
<!-- Access Log Modal -->
<AccessLogModal
v-model:isOpen="isAccessLogModalOpen"
:postId="selectedPostId"
:postTitle="selectedPostTitle"
/>
2026-01-15 13:51:44 +08:00
</div>
</template>
<script setup lang="ts">
2026-01-23 16:05:23 +08:00
import { ref, onMounted, computed } from 'vue'
2026-06-25 17:57:51 +08:00
import { getAdminPosts, deletePost as deletePostApi, Post, togglePostStatus, getAdminCategories, getAdminColumns } from '../../services/api'
2026-01-15 13:51:44 +08:00
import { useToast } from '../../composables/useToast'
2026-01-19 16:14:08 +08:00
import PostRelationModal from '../../components/admin/PostRelationModal.vue'
2026-06-25 16:57:06 +08:00
import PostHistoryModal from '../../components/admin/PostHistoryModal.vue'
2026-01-20 11:00:42 +08:00
import AccessLogModal from '../../components/admin/AccessLogModal.vue'
2026-06-25 17:57:51 +08:00
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
import CustomSelect from '../../components/CustomSelect.vue'
import AuthorInfo from '../../components/AuthorInfo.vue'
2026-01-15 13:51:44 +08:00
2026-06-25 16:57:06 +08:00
type RelationFocusSection = 'category' | 'column' | 'tags' | 'all'
2026-01-15 13:51:44 +08:00
const toast = useToast()
const posts = ref<Post[]>([])
2026-01-19 16:14:08 +08:00
const isRelationModalOpen = ref(false)
const editingPost = ref<Post | null>(null)
2026-06-25 16:57:06 +08:00
const relationFocusSection = ref<RelationFocusSection>('all')
const isHistoryModalOpen = ref(false)
const historyPostId = ref<number | string>('')
2026-01-20 11:00:42 +08:00
const isAccessLogModalOpen = ref(false)
const selectedPostId = ref<number | null>(null)
const selectedPostTitle = ref<string>('')
2026-01-19 16:14:08 +08:00
2026-01-23 16:05:23 +08:00
// 搜索和分页状态
const searchQuery = ref('')
2026-06-25 17:57:51 +08:00
const filterCategoryId = ref<number | string>(0)
const filterColumnId = ref<number | string>(0)
const filterPublished = ref<number | string>('')
const startDate = ref('')
const endDate = ref('')
2026-01-23 16:05:23 +08:00
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const loading = ref(false)
2026-06-25 17:57:51 +08:00
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: '草稿' },
]
2026-01-23 16:05:23 +08:00
// 计算总页数
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
})
2026-06-25 16:57:06 +08:00
const openRelationModal = (post: Post, section: RelationFocusSection = 'all') => {
2026-01-19 16:14:08 +08:00
editingPost.value = post
2026-06-25 16:57:06 +08:00
relationFocusSection.value = section
2026-01-19 16:14:08 +08:00
isRelationModalOpen.value = true
}
2026-01-15 13:51:44 +08:00
2026-06-25 16:57:06 +08:00
const openHistoryModal = (post: Post) => {
historyPostId.value = post.id
isHistoryModalOpen.value = true
}
2026-01-20 11:00:42 +08:00
const openAccessLogModal = (post: Post) => {
selectedPostId.value = post.id
selectedPostTitle.value = post.title
isAccessLogModalOpen.value = true
}
2026-01-15 13:51:44 +08:00
const fetchPosts = async () => {
2026-01-23 16:05:23 +08:00
loading.value = true
2026-01-15 13:51:44 +08:00
try {
2026-06-25 17:57:51 +08:00
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)
2026-01-16 17:03:34 +08:00
if ('list' in response) {
posts.value = response.list
2026-01-23 16:05:23 +08:00
total.value = response.total
2026-01-16 17:03:34 +08:00
} else {
posts.value = response as any
2026-01-23 16:05:23 +08:00
total.value = (response as any).length || 0
2026-01-16 17:03:34 +08:00
}
2026-01-15 13:51:44 +08:00
} catch (error) {
console.error('Error fetching posts:', error)
2026-01-16 11:06:59 +08:00
toast.showToast('获取文章列表失败', 'error')
2026-01-23 16:05:23 +08:00
} finally {
loading.value = false
}
}
const handleSearch = () => {
currentPage.value = 1
fetchPosts()
}
2026-06-25 17:57:51 +08:00
const resetFilters = () => {
2026-01-23 16:05:23 +08:00
searchQuery.value = ''
2026-06-25 17:57:51 +08:00
filterCategoryId.value = 0
filterColumnId.value = 0
filterPublished.value = ''
startDate.value = ''
endDate.value = ''
2026-01-23 16:05:23 +08:00
currentPage.value = 1
fetchPosts()
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
fetchPosts()
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
2026-01-15 13:51:44 +08:00
}
}
2026-01-16 17:03:34 +08:00
const handleToggleStatus = async (post: Post) => {
const newStatus = post.isPublished === 1 ? 0 : 1
try {
await togglePostStatus(post.id, newStatus)
post.isPublished = newStatus
2026-06-26 13:37:16 +08:00
toast.success(newStatus === 1 ? '文章已发布' : '文章已转为草稿')
2026-01-16 17:03:34 +08:00
} catch (error) {
console.error('Error toggling status:', error)
2026-06-26 13:37:16 +08:00
toast.error('更新状态失败')
2026-01-16 17:03:34 +08:00
}
}
2026-01-15 17:03:59 +08:00
const deletePost = async (id: number) => {
2026-01-15 13:51:44 +08:00
if (confirm('确定要删除这篇文章吗?')) {
try {
await deletePostApi(id)
2026-01-16 11:06:59 +08:00
toast.showToast('文章删除成功', 'success')
2026-01-15 13:51:44 +08:00
fetchPosts()
} catch (error) {
console.error('Error deleting post:', error)
2026-01-16 11:06:59 +08:00
toast.showToast('删除文章失败', 'error')
2026-01-15 13:51:44 +08:00
}
}
}
2026-06-25 17:57:51 +08:00
onMounted(async () => {
try {
const [cats, cols] = await Promise.all([getAdminCategories(), getAdminColumns()])
categories.value = cats
columns.value = cols
} catch { /* ignore */ }
2026-01-15 13:51:44 +08:00
fetchPosts()
})
</script>
<style scoped>
2026-01-16 11:06:59 +08:00
/* Scoped styles removed, using Tailwind classes */
</style>