Files
nl_cms-view/src/views/admin/articles/index.vue
2025-07-31 10:00:19 +08:00

619 lines
22 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="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>