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
|
|
|
|
<!-- 搜索和筛选区域 -->
|
|
|
|
|
|
<div class="admin-card mb-6">
|
|
|
|
|
|
<div class="flex items-center gap-4">
|
|
|
|
|
|
<div class="flex-1 relative">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
v-model="searchQuery"
|
|
|
|
|
|
@keyup.enter="handleSearch"
|
|
|
|
|
|
placeholder="搜索文章标题或内容..."
|
|
|
|
|
|
class="admin-input w-full pl-10"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="absolute left-3 top-1/2 -translate-y-1/2 text-art-muted">
|
|
|
|
|
|
<circle cx="11" cy="11" r="8"></circle>
|
|
|
|
|
|
<path d="m21 21-4.35-4.35"></path>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="searchQuery"
|
|
|
|
|
|
@click="clearSearch"
|
|
|
|
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-art-muted hover:text-white transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<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="18" y1="6" x2="6" y2="18"></line>
|
|
|
|
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button @click="handleSearch" class="admin-btn-secondary">
|
|
|
|
|
|
搜索
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
<th>标题</th>
|
|
|
|
|
|
<th>分类</th>
|
2026-01-19 16:14:08 +08:00
|
|
|
|
<th>专栏</th>
|
|
|
|
|
|
<th>标签</th>
|
2026-01-16 11:06:59 +08:00
|
|
|
|
<th>发布日期</th>
|
|
|
|
|
|
<th>状态</th>
|
2026-01-20 11:00:42 +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">
|
|
|
|
|
|
<td class="font-mono text-xs text-white/40">#{{ post.id }}</td>
|
|
|
|
|
|
<td class="font-medium text-white group-hover:text-art-accent transition-colors">{{ post.title }}</td>
|
2026-01-16 17:03:34 +08:00
|
|
|
|
<td><span class="px-2 py-1 rounded bg-white/5 border border-white/5 text-xs text-art-muted">{{ post.categoryName || post.category?.name || '未分类' }}</span></td>
|
2026-01-19 16:14:08 +08:00
|
|
|
|
<td>
|
2026-01-20 09:13:51 +08:00
|
|
|
|
<span v-if="post.columnName" class="px-2 py-1 rounded bg-art-accent/10 border border-art-accent/20 text-xs text-art-accent">
|
|
|
|
|
|
{{ post.columnName }}
|
2026-01-19 16:14:08 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
<span v-else class="text-art-muted text-xs">-</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-1">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-for="tag in post.tags.slice(0, 3)"
|
|
|
|
|
|
:key="tag.id"
|
|
|
|
|
|
class="px-2 py-0.5 rounded bg-white/5 border border-white/5 text-xs text-art-muted"
|
|
|
|
|
|
>
|
|
|
|
|
|
#{{ tag.name }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span v-if="post.tags.length > 3" class="text-art-muted text-xs">+{{ post.tags.length - 3 }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span v-else class="text-art-muted text-xs">-</span>
|
|
|
|
|
|
</td>
|
2026-01-16 11:06:59 +08:00
|
|
|
|
<td class="text-art-muted text-xs">{{ post.date }}</td>
|
|
|
|
|
|
<td>
|
2026-01-16 17:03:34 +08:00
|
|
|
|
<button
|
|
|
|
|
|
@click="handleToggleStatus(post)"
|
|
|
|
|
|
:class="['px-2 py-1 rounded-full text-xs font-medium border transition-all hover:opacity-80', 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="点击切换状态"
|
|
|
|
|
|
>
|
2026-01-16 11:06:59 +08:00
|
|
|
|
{{ post.isPublished === 1 ? '已发布' : '草稿' }}
|
2026-01-16 17:03:34 +08:00
|
|
|
|
</button>
|
2026-01-16 11:06:59 +08:00
|
|
|
|
</td>
|
2026-01-20 11:00:42 +08:00
|
|
|
|
<td>
|
|
|
|
|
|
<button
|
|
|
|
|
|
@click="openAccessLogModal(post)"
|
|
|
|
|
|
class="admin-btn-secondary py-1 px-3 text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
|
|
|
|
|
title="查看访问记录"
|
|
|
|
|
|
>
|
|
|
|
|
|
查看
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</td>
|
2026-01-16 11:06:59 +08:00
|
|
|
|
<td class="text-right">
|
|
|
|
|
|
<div class="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
2026-01-19 16:14:08 +08:00
|
|
|
|
<button @click="openRelationModal(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"
|
|
|
|
|
|
@saved="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-01-23 16:13:39 +08:00
|
|
|
|
import { getAdminPosts, deletePost as deletePostApi, Post, togglePostStatus } 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-01-20 11:00:42 +08:00
|
|
|
|
import AccessLogModal from '../../components/admin/AccessLogModal.vue'
|
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-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('')
|
|
|
|
|
|
const currentPage = ref(1)
|
|
|
|
|
|
const pageSize = ref(20)
|
|
|
|
|
|
const total = ref(0)
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
// 计算总页数
|
|
|
|
|
|
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-01-19 16:14:08 +08:00
|
|
|
|
const openRelationModal = (post: Post) => {
|
|
|
|
|
|
editingPost.value = post
|
|
|
|
|
|
isRelationModalOpen.value = true
|
|
|
|
|
|
}
|
2026-01-15 13:51:44 +08:00
|
|
|
|
|
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-01-23 16:05:23 +08:00
|
|
|
|
const response = await getAdminPosts(currentPage.value, pageSize.value, searchQuery.value)
|
2026-01-16 17:03:34 +08:00
|
|
|
|
// Handle PaginationResponse structure
|
|
|
|
|
|
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 {
|
|
|
|
|
|
// Fallback if API returns array directly (legacy)
|
|
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const clearSearch = () => {
|
|
|
|
|
|
searchQuery.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' })
|
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
|
|
|
|
|
|
toast.showToast(newStatus === 1 ? '文章已发布' : '文章已转为草稿', 'success')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error toggling status:', error)
|
|
|
|
|
|
toast.showToast('更新状态失败', 'error')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
fetchPosts()
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-01-16 11:06:59 +08:00
|
|
|
|
/* Scoped styles removed, using Tailwind classes */
|
|
|
|
|
|
</style>
|