Files
nl_cms-view/src/views/admin/articles/index.vue

619 lines
22 KiB
Vue
Raw Normal View History

2025-07-31 10:00:19 +08:00
<template>
<div class="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 p-6">
<!-- 页面头部 -->
<div class="mb-8" data-aos="fade-down">
<div class="bg-glass rounded-3xl p-6 border border-white/10">
<div class="flex flex-col md:flex-row md:items-center md:justify-between">
<div class="mb-4 md:mb-0">
<h1 class="text-3xl font-bold text-gradient mb-2">文章管理</h1>
<p class="text-gray-300">管理和编辑您的文章内容</p>
</div>
<div class="flex space-x-4">
<button class="tech-button-primary" @click="createArticle">
<PlusIcon class="w-5 h-5 mr-2" />
新建文章
</button>
<button class="tech-button-secondary" @click="refreshData">
<RefreshIcon class="w-5 h-5 mr-2" />
刷新
</button>
</div>
</div>
</div>
</div>
<!-- 筛选和搜索 -->
<div class="mb-8" data-aos="fade-up" data-aos-delay="100">
<div class="bg-glass rounded-3xl p-6 border border-white/10">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- 搜索框 -->
<div class="md:col-span-2">
<div class="relative">
<SearchIcon class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
v-model="searchKeyword"
type="text"
placeholder="搜索文章标题或内容..."
class="tech-input pl-10"
@keyup.enter="searchArticles"
/>
</div>
</div>
<!-- 状态筛选 -->
<div>
<select v-model="statusFilter" class="tech-select">
<option value="">全部状态</option>
<option value="published">已发布</option>
<option value="draft">草稿</option>
<option value="archived">已归档</option>
</select>
</div>
<!-- 分类筛选 -->
<div>
<select v-model="categoryFilter" class="tech-select">
<option value="">全部分类</option>
<option value="tech">技术</option>
<option value="news">新闻</option>
<option value="tutorial">教程</option>
</select>
</div>
</div>
</div>
</div>
<!-- 文章列表 -->
<div class="mb-8" data-aos="fade-up" data-aos-delay="200">
<div class="bg-glass rounded-3xl border border-white/10 overflow-hidden">
<!-- 表格头部 -->
<div class="px-6 py-4 border-b border-white/10">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white">文章列表</h3>
<div class="flex items-center space-x-4 text-sm text-gray-400">
<span> {{ totalArticles }} 篇文章</span>
<div class="flex items-center space-x-2">
<span>每页显示</span>
<select v-model="pageSize" class="bg-white/10 border border-white/20 rounded px-2 py-1 text-white">
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
</div>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-tech-blue-500"></div>
<span class="ml-3 text-gray-400">加载中...</span>
</div>
<!-- 空状态 -->
<div v-else-if="!articles.length" class="py-12">
<TechEmptyState
title="暂无文章"
description="还没有创建任何文章,点击上方按钮开始创建您的第一篇文章"
:show-action="true"
action-text="创建文章"
@action="createArticle"
/>
</div>
<!-- 文章表格 -->
<div v-else class="overflow-x-auto">
<table class="w-full">
<thead class="bg-white/5">
<tr>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
<input type="checkbox" v-model="selectAll" @change="toggleSelectAll" class="tech-checkbox" />
</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">标题</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">状态</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">分类</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">作者</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">创建时间</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-white/10">
<tr
v-for="(article, index) in articles"
:key="article.id"
class="hover:bg-white/5 transition-colors duration-200"
:data-aos="'fade-up'"
:data-aos-delay="index * 50"
>
<td class="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
v-model="selectedArticles"
:value="article.id"
class="tech-checkbox"
/>
</td>
<td class="px-6 py-4">
<div class="flex items-center">
<div class="flex-shrink-0 h-12 w-12">
<img
:src="article.cover_image || '/placeholder-tech.jpg'"
:alt="article.title"
class="h-12 w-12 rounded-lg object-cover border border-white/20"
/>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-white hover:text-tech-blue-300 cursor-pointer" @click="editArticle(article.id)">
{{ article.title }}
</div>
<div class="text-sm text-gray-400 line-clamp-1">
{{ article.summary || '暂无摘要' }}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="[getStatusClass(article.status), 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium']">
{{ getStatusText(article.status) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
{{ article.category || '未分类' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
{{ article.author || '未知' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
{{ formatDate(article.created_at) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex items-center space-x-2">
<button @click="editArticle(article.id)" class="tech-button-ghost-sm">
<EditIcon class="w-4 h-4" />
</button>
<button @click="viewArticle(article.id)" class="tech-button-ghost-sm">
<EyeIcon class="w-4 h-4" />
</button>
<button @click="deleteArticle(article.id)" class="tech-button-ghost-sm text-red-400 hover:text-red-300">
<TrashIcon class="w-4 h-4" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 批量操作栏 -->
<div v-if="selectedArticles.length > 0" class="px-6 py-4 bg-white/5 border-t border-white/10">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-300">已选择 {{ selectedArticles.length }} 篇文章</span>
<div class="flex items-center space-x-4">
<button @click="batchPublish" class="tech-button-ghost-sm">
<CheckIcon class="w-4 h-4 mr-1" />
批量发布
</button>
<button @click="batchArchive" class="tech-button-ghost-sm">
<ArchiveIcon class="w-4 h-4 mr-1" />
批量归档
</button>
<button @click="batchDelete" class="tech-button-ghost-sm text-red-400 hover:text-red-300">
<TrashIcon class="w-4 h-4 mr-1" />
批量删除
</button>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="px-6 py-4 border-t border-white/10">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-400">
显示第 {{ (currentPage - 1) * pageSize + 1 }} - {{ Math.min(currentPage * pageSize, totalArticles) }} {{ totalArticles }}
</div>
<div class="flex items-center space-x-2">
<button
@click="changePage(currentPage - 1)"
:disabled="currentPage <= 1"
class="tech-button-ghost-sm"
:class="{ 'opacity-50 cursor-not-allowed': currentPage <= 1 }"
>
<ChevronLeftIcon class="w-4 h-4" />
</button>
<div class="flex items-center space-x-1">
<button
v-for="page in getPageNumbers()"
:key="page"
@click="changePage(page)"
:class="[
'px-3 py-1 rounded-lg text-sm transition-colors duration-200',
page === currentPage
? 'bg-tech-gradient text-white'
: 'text-gray-400 hover:text-white hover:bg-white/10'
]"
>
{{ page }}
</button>
</div>
<button
@click="changePage(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="tech-button-ghost-sm"
:class="{ 'opacity-50 cursor-not-allowed': currentPage >= totalPages }"
>
<ChevronRightIcon class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import TechEmptyState from '@/components/TechEmptyState.vue'
const router = useRouter()
// 图标组件
const PlusIcon = {
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>`
}
const RefreshIcon = {
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>`
}
const SearchIcon = {
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>`
}
const EditIcon = {
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>`
}
const EyeIcon = {
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>`
}
const TrashIcon = {
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>`
}
const CheckIcon = {
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>`
}
const ArchiveIcon = {
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path></svg>`
}
const ChevronLeftIcon = {
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>`
}
const ChevronRightIcon = {
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>`
}
// 响应式数据
const loading = ref(false)
const articles = ref([])
const searchKeyword = ref('')
const statusFilter = ref('')
const categoryFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const totalArticles = ref(0)
const selectAll = ref(false)
const selectedArticles = ref([])
// 计算属性
const totalPages = computed(() => Math.ceil(totalArticles.value / pageSize.value))
// 模拟数据
const mockArticles = [
{
id: 1,
title: 'Vue3 Composition API 深度解析',
summary: '详细介绍Vue3 Composition API的使用方法和最佳实践',
status: 'published',
category: 'tech',
author: '张三',
created_at: '2024-01-15T10:30:00Z',
cover_image: 'https://images.unsplash.com/photo-1517077304055-6e89abbf09b0?w=400'
},
{
id: 2,
title: 'React vs Vue 性能对比分析',
summary: '从多个维度对比React和Vue的性能表现',
status: 'draft',
category: 'tech',
author: '李四',
created_at: '2024-01-14T15:20:00Z',
cover_image: 'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=400'
},
{
id: 3,
title: 'TypeScript 入门教程',
summary: '从零开始学习TypeScript的基础知识',
status: 'published',
category: 'tutorial',
author: '王五',
created_at: '2024-01-13T09:15:00Z',
cover_image: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=400'
}
]
// 方法
const fetchArticles = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500))
let filteredArticles = [...mockArticles]
// 搜索过滤
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
filteredArticles = filteredArticles.filter(article =>
article.title.toLowerCase().includes(keyword) ||
article.summary.toLowerCase().includes(keyword)
)
}
// 状态过滤
if (statusFilter.value) {
filteredArticles = filteredArticles.filter(article => article.status === statusFilter.value)
}
// 分类过滤
if (categoryFilter.value) {
filteredArticles = filteredArticles.filter(article => article.category === categoryFilter.value)
}
totalArticles.value = filteredArticles.length
// 分页
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
articles.value = filteredArticles.slice(start, end)
} catch (error) {
console.error('获取文章列表失败:', error)
} finally {
loading.value = false
}
}
const searchArticles = () => {
currentPage.value = 1
fetchArticles()
}
const refreshData = () => {
fetchArticles()
}
const createArticle = () => {
router.push('/admin/articles/create')
}
const editArticle = (id) => {
router.push(`/admin/articles/edit/${id}`)
}
const viewArticle = (id) => {
router.push(`/articles/${id}`)
}
const deleteArticle = async (id) => {
if (confirm('确定要删除这篇文章吗?')) {
// 模拟删除操作
console.log('删除文章:', id)
await fetchArticles()
}
}
const toggleSelectAll = () => {
if (selectAll.value) {
selectedArticles.value = articles.value.map(article => article.id)
} else {
selectedArticles.value = []
}
}
const batchPublish = async () => {
if (selectedArticles.value.length === 0) return
console.log('批量发布:', selectedArticles.value)
selectedArticles.value = []
selectAll.value = false
await fetchArticles()
}
const batchArchive = async () => {
if (selectedArticles.value.length === 0) return
console.log('批量归档:', selectedArticles.value)
selectedArticles.value = []
selectAll.value = false
await fetchArticles()
}
const batchDelete = async () => {
if (selectedArticles.value.length === 0) return
if (confirm(`确定要删除选中的 ${selectedArticles.value.length} 篇文章吗?`)) {
console.log('批量删除:', selectedArticles.value)
selectedArticles.value = []
selectAll.value = false
await fetchArticles()
}
}
const changePage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
fetchArticles()
}
}
const getPageNumbers = () => {
const pages = []
const total = totalPages.value
const current = currentPage.value
if (total <= 7) {
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
if (current <= 4) {
for (let i = 1; i <= 5; i++) {
pages.push(i)
}
pages.push('...')
pages.push(total)
} else if (current >= total - 3) {
pages.push(1)
pages.push('...')
for (let i = total - 4; i <= total; i++) {
pages.push(i)
}
} else {
pages.push(1)
pages.push('...')
for (let i = current - 1; i <= current + 1; i++) {
pages.push(i)
}
pages.push('...')
pages.push(total)
}
}
return pages
}
const getStatusClass = (status) => {
const classes = {
published: 'bg-green-500/20 text-green-400',
draft: 'bg-yellow-500/20 text-yellow-400',
archived: 'bg-gray-500/20 text-gray-400'
}
return classes[status] || 'bg-gray-500/20 text-gray-400'
}
const getStatusText = (status) => {
const texts = {
published: '已发布',
draft: '草稿',
archived: '已归档'
}
return texts[status] || '未知'
}
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 组件挂载时获取数据
onMounted(() => {
fetchArticles()
})
// 监听筛选条件变化
import { watch } from 'vue'
watch([statusFilter, categoryFilter, pageSize], () => {
currentPage.value = 1
fetchArticles()
})
// 监听选中状态
watch(selectedArticles, (newVal) => {
selectAll.value = newVal.length === articles.value.length && articles.value.length > 0
})
</script>
<style lang='scss' scoped>
/* 科技感按钮样式 */
.tech-button-primary {
@apply px-6 py-3 bg-tech-gradient text-white rounded-2xl font-medium transition-all duration-300 hover:shadow-lg hover:shadow-tech-blue-500/25 active:scale-95 flex items-center;
}
.tech-button-secondary {
@apply px-6 py-3 bg-white/10 border border-white/20 text-white rounded-2xl font-medium transition-all duration-300 hover:bg-white/20 active:scale-95 flex items-center;
}
.tech-button-ghost-sm {
@apply p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors duration-200 flex items-center;
}
/* 科技感输入框样式 */
.tech-input {
@apply w-full px-4 py-3 bg-white/10 border border-white/20 rounded-2xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-tech-blue-500 focus:border-transparent transition-all duration-300;
}
.tech-select {
@apply w-full px-4 py-3 bg-white/10 border border-white/20 rounded-2xl text-white focus:outline-none focus:ring-2 focus:ring-tech-blue-500 focus:border-transparent transition-all duration-300;
}
/* 科技感复选框样式 */
.tech-checkbox {
@apply w-4 h-4 text-tech-blue-500 bg-white/10 border-white/20 rounded focus:ring-tech-blue-500 focus:ring-2;
}
/* 玻璃拟态背景 */
.bg-glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* 渐变文字 */
.text-gradient {
background: linear-gradient(135deg, #60a5fa, #c084fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 科技渐变背景 */
.bg-tech-gradient {
background: linear-gradient(135deg, #2563EB 0%, #A855F7 100%);
}
/* 文本截断 */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 响应式优化 */
@media (max-width: 768px) {
.grid-cols-1.md\:grid-cols-4 {
grid-template-columns: 1fr;
}
.flex-col.md\:flex-row {
flex-direction: column;
}
.md\:col-span-2 {
grid-column: span 1;
}
}
</style>