feat: add prime vue

This commit is contained in:
yanghao05
2025-04-01 17:51:50 +08:00
parent f696dfdfe8
commit fe9a80405d
12 changed files with 339 additions and 1023 deletions

View File

@@ -1,135 +1,91 @@
<template>
<div class="home-page">
<h1>仪表盘</h1>
<div class="statistics-container">
<div class="stat-card" @click="navigateTo('/posts')">
<div class="stat-icon">
<i class="fa fa-file-text"></i>
</div>
<div class="stat-content">
<h2>文章数量</h2>
<div class="stat-value">{{ postCount }}</div>
</div>
</div>
<div class="stat-card" @click="navigateTo('/medias')">
<div class="stat-icon">
<i class="fa fa-image"></i>
</div>
<div class="stat-content">
<h2>媒体数量</h2>
<div class="stat-value">{{ mediaCount }}</div>
</div>
</div>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
</div>
</template>
<script setup>
import Button from 'primevue/button';
import Card from 'primevue/card';
import Message from 'primevue/message';
import ProgressSpinner from 'primevue/progressspinner';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { statisticsService } from '../api/statisticsService';
import { statsApi } from '../api/statsApi';
const router = useRouter();
const postCount = ref(0);
const mediaCount = ref(0);
const error = ref('');
const loading = ref(false);
const articleCount = ref(0);
const loading = ref(true);
const error = ref(null);
const fetchStatistics = async () => {
const fetchCounts = async () => {
loading.value = true;
error.value = '';
error.value = null;
try {
// Option 1: Make parallel requests using our service
const [postsData, mediasData] = await Promise.all([
statisticsService.getPostsCount(),
statisticsService.getMediasCount()
// Use the API service instead of direct fetch calls
const [mediaData, articleData] = await Promise.all([
statsApi.getMediaCount(),
statsApi.getArticleCount()
]);
postCount.value = postsData.count;
mediaCount.value = mediasData.count;
// Option 2: If you have a combined endpoint
// const data = await statisticsService.getAllStatistics();
// postCount.value = data.posts;
// mediaCount.value = data.medias;
mediaCount.value = mediaData.count;
articleCount.value = articleData.count;
} catch (err) {
console.error('Error fetching statistics:', err);
error.value = '获取统计数据时出错';
console.error('Error fetching data:', err);
error.value = 'Failed to load data. Please try again later.';
} finally {
loading.value = false;
}
};
const navigateTo = (path) => {
router.push(path);
};
onMounted(() => {
fetchStatistics();
fetchCounts();
});
</script>
<style scoped>
.home-page {
padding: 20px;
}
<template>
<div class="w-full">
<div class="flex justify-between items-center mb-8 pb-2">
<h1 class="m-0 text-2xl text-gray-800 font-medium">Dashboard</h1>
<Button @click="fetchCounts" icon="pi pi-refresh" rounded text aria-label="Refresh" />
</div>
.statistics-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-top: 20px;
}
<div v-if="loading" class="flex flex-col items-center justify-center py-12 text-center min-h-[200px]">
<ProgressSpinner />
<p class="mt-4 text-gray-500">Loading data...</p>
</div>
.stat-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
display: flex;
min-width: 250px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
<div v-else-if="error" class="flex flex-col items-center justify-center py-12 text-center min-h-[200px]">
<Message severity="error" :closable="false">{{ error }}</Message>
<Button @click="fetchCounts" label="Retry" icon="pi pi-refresh" severity="secondary" class="mt-3" />
</div>
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
}
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 my-8">
<Card class="transition-all duration-200 hover:-translate-y-1 hover:shadow-lg">
<template #header>
<div class="flex items-center justify-center p-4 bg-primary bg-opacity-10">
<i class="pi pi-image text-4xl! text-white"></i>
</div>
</template>
<template #title>Media</template>
<template #content>
<div class="text-4xl font-bold mb-2 text-primary">{{ mediaCount }}</div>
<div class="text-base text-gray-500 mb-4">Total Media Items</div>
</template>
<template #footer>
<Button label="View All Media" icon="pi pi-arrow-right" link />
</template>
</Card>
.stat-icon {
font-size: 2.5rem;
margin-right: 20px;
color: #3498db;
display: flex;
align-items: center;
}
.stat-content h2 {
margin: 0 0 10px 0;
font-size: 1.2rem;
color: #555;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #333;
}
.error-message {
margin-top: 20px;
padding: 10px;
background-color: #ffecec;
color: #f44336;
border-radius: 4px;
border-left: 4px solid #f44336;
}
</style>
<Card class="transition-all duration-200 hover:-translate-y-1 hover:shadow-lg">
<template #header>
<div class="flex items-center justify-center p-4 bg-primary bg-opacity-10">
<i class="pi pi-file text-4xl! text-white"></i>
</div>
</template>
<template #title>Articles</template>
<template #content>
<div class="text-4xl font-bold mb-2 text-primary">{{ articleCount }}</div>
<div class="text-base text-gray-500 mb-4">Total Articles</div>
</template>
<template #footer>
<Button label="View All Articles" icon="pi pi-arrow-right" link />
</template>
</Card>
</div>
</div>
</template>

View File

@@ -1,481 +0,0 @@
<template>
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-semibold text-gray-800">Media Library</h1>
<div class="flex space-x-2">
<div class="relative">
<input type="text" placeholder="Search media..."
class="pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
v-model="searchQuery" @input="filterMedia">
<i class="iconfont icon-search absolute left-3 top-3 text-gray-400"></i>
</div>
<select class="border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
v-model="selectedType" @change="filterMedia">
<option value="all">All media</option>
<option value="image">Images</option>
<option value="video">Videos</option>
<option value="audio">Audio</option>
<option value="document">Documents</option>
<option value="pdf">PDFs</option>
</select>
</div>
</div>
<!-- Upload Area -->
<div id="upload-area" :class="{ 'active': isDragging }"
class="upload-area rounded-lg p-10 mb-8 flex flex-col items-center justify-center cursor-pointer"
@click="triggerFileInput" @dragenter.prevent="onDragEnter" @dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave" @drop.prevent="onDrop">
<i class="iconfont icon-upload text-5xl text-blue-500 mb-4"></i>
<p class="text-gray-600 text-center mb-2">Drag and drop files here or click to browse</p>
<p class="text-gray-400 text-sm text-center">Supports: JPG, PNG, GIF, MP4, PDF</p>
<input type="file" id="file-input" multiple class="hidden" ref="fileInput" @change="onFileSelected">
</div>
<!-- Media Table Section -->
<div class="mb-12 bg-white rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full media-table">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<div class="flex items-center">
File Name
<button class="ml-1 text-gray-400 hover:text-gray-600" @click="sortBy('fileName')">
<i class="iconfont icon-sort"></i>
</button>
</div>
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<div class="flex items-center">
Upload Time
<button class="ml-1 text-gray-400 hover:text-gray-600"
@click="sortBy('uploadTime')">
<i class="iconfont icon-sort"></i>
</button>
</div>
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<div class="flex items-center">
File Size
<button class="ml-1 text-gray-400 hover:text-gray-600" @click="sortBy('fileSize')">
<i class="iconfont icon-sort"></i>
</button>
</div>
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<div class="flex items-center">
File Type
<button class="ml-1 text-gray-400 hover:text-gray-600" @click="sortBy('fileType')">
<i class="iconfont icon-sort"></i>
</button>
</div>
</th>
<th
class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="file in paginatedFiles" :key="file.id" class="table-row">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 mr-3" v-if="file.fileType === 'Image'">
<img class="preview-thumbnail" :src="file.thumbnailUrl" :alt="file.fileName">
</div>
<div v-else
class="flex-shrink-0 h-10 w-10 mr-3 bg-gray-100 rounded flex items-center justify-center">
<i :class="getFileTypeIcon(file.fileType)" class="text-2xl"></i>
</div>
<div class="text-sm font-medium text-gray-900">{{ file.fileName }}</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ file.uploadTime }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ file.fileSize }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
:class="getFileTypeBadgeClass(file.fileType)">
{{ file.fileType }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-medium">
<div class="flex justify-center space-x-2">
<button class="p-1 text-blue-600 hover:text-blue-900 focus:outline-none"
title="Preview" @click="previewFile(file)">
<i
:class="file.fileType === 'Video' || file.fileType === 'Audio' ? 'iconfont icon-play' : 'iconfont icon-eye'"></i>
</button>
<button class="p-1 text-gray-600 hover:text-gray-900 focus:outline-none"
title="Download" @click="downloadFile(file)">
<i class="iconfont icon-download"></i>
</button>
<button class="p-1 text-red-600 hover:text-red-900 focus:outline-none"
title="Delete" @click="deleteFile(file)">
<i class="iconfont icon-delete"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Table footer with pagination -->
<div class="bg-gray-50 px-6 py-3 border-t border-gray-200 flex items-center justify-between">
<div class="flex-1 flex justify-between sm:hidden">
<button
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
@click="prevPage" :disabled="currentPage === 1"
:class="{ 'opacity-50 cursor-not-allowed': currentPage === 1 }">
Previous
</button>
<button
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
@click="nextPage" :disabled="currentPage === totalPages"
:class="{ 'opacity-50 cursor-not-allowed': currentPage === totalPages }">
Next
</button>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Showing <span class="font-medium">{{ startItem }}</span> to <span class="font-medium">{{
endItem }}</span> of <span class="font-medium">{{ filteredFiles.length }}</span> results
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
@click="prevPage" :disabled="currentPage === 1"
:class="{ 'opacity-50 cursor-not-allowed': currentPage === 1 }">
<span class="sr-only">Previous</span>
<i class="iconfont icon-arrow-left"></i>
</button>
<button v-for="page in paginationPages" :key="page" :class="getPageButtonClass(page)"
@click="goToPage(page)">
{{ page }}
</button>
<span v-if="showEllipsis"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
...
</span>
<button v-if="totalPages > 3" :class="getPageButtonClass(totalPages)"
@click="goToPage(totalPages)">
{{ totalPages }}
</button>
<button
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
@click="nextPage" :disabled="currentPage === totalPages"
:class="{ 'opacity-50 cursor-not-allowed': currentPage === totalPages }">
<span class="sr-only">Next</span>
<i class="iconfont icon-arrow-right"></i>
</button>
</nav>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
// Reactive state
const fileInput = ref(null);
const mediaFiles = ref([
{
id: 1,
fileName: 'sunset-beach.jpg',
uploadTime: 'Today, 10:30 AM',
fileSize: '2.4 MB',
fileType: 'Image',
thumbnailUrl: 'https://via.placeholder.com/300x225'
},
{
id: 2,
fileName: 'presentation.pdf',
uploadTime: 'Yesterday, 3:45 PM',
fileSize: '4.8 MB',
fileType: 'PDF',
thumbnailUrl: null
},
{
id: 3,
fileName: 'promo_video.mp4',
uploadTime: 'Aug 28, 2023',
fileSize: '24.8 MB',
fileType: 'Video',
thumbnailUrl: null
},
{
id: 4,
fileName: 'report_q3.docx',
uploadTime: 'Aug 25, 2023',
fileSize: '1.2 MB',
fileType: 'Document',
thumbnailUrl: null
},
{
id: 5,
fileName: 'podcast_interview.mp3',
uploadTime: 'Aug 20, 2023',
fileSize: '18.5 MB',
fileType: 'Audio',
thumbnailUrl: null
}
]);
const searchQuery = ref('');
const selectedType = ref('all');
const currentPage = ref(1);
const itemsPerPage = ref(5);
const isDragging = ref(false);
const sortOrder = ref({
field: null,
direction: 'asc'
});
// Computed properties
const filteredFiles = computed(() => {
let result = [...mediaFiles.value];
// Search filter
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter(file =>
file.fileName.toLowerCase().includes(query)
);
}
// Type filter
if (selectedType.value !== 'all') {
const type = selectedType.value.charAt(0).toUpperCase() + selectedType.value.slice(1);
result = result.filter(file =>
file.fileType.toLowerCase() === selectedType.value
);
}
// Sorting
if (sortOrder.value.field) {
result.sort((a, b) => {
if (sortOrder.value.direction === 'asc') {
return a[sortOrder.value.field] > b[sortOrder.value.field] ? 1 : -1;
} else {
return a[sortOrder.value.field] < b[sortOrder.value.field] ? 1 : -1;
}
});
}
return result;
});
const paginatedFiles = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value;
const end = start + itemsPerPage.value;
return filteredFiles.value.slice(start, end);
});
const totalPages = computed(() => {
return Math.ceil(filteredFiles.value.length / itemsPerPage.value);
});
const startItem = computed(() => {
if (filteredFiles.value.length === 0) return 0;
return (currentPage.value - 1) * itemsPerPage.value + 1;
});
const endItem = computed(() => {
if (filteredFiles.value.length === 0) return 0;
const end = currentPage.value * itemsPerPage.value;
return end > filteredFiles.value.length ? filteredFiles.value.length : end;
});
const paginationPages = computed(() => {
// For simple pagination, just show first 3 pages
const pages = [];
const maxPages = Math.min(3, totalPages.value);
for (let i = 1; i <= maxPages; i++) {
pages.push(i);
}
return pages;
});
const showEllipsis = computed(() => {
return totalPages.value > 3;
});
// Methods
const getFileTypeIcon = (fileType) => {
switch (fileType) {
case 'PDF':
return 'iconfont icon-file-pdf text-red-500';
case 'Video':
return 'iconfont icon-file-video text-blue-500';
case 'Document':
return 'iconfont icon-file-word text-blue-700';
case 'Audio':
return 'iconfont icon-file-audio text-green-600';
default:
return 'iconfont icon-file text-gray-500';
}
};
const getFileTypeBadgeClass = (fileType) => {
switch (fileType) {
case 'Image':
return 'bg-blue-100 text-blue-800';
case 'PDF':
return 'bg-red-100 text-red-800';
case 'Video':
return 'bg-purple-100 text-purple-800';
case 'Document':
return 'bg-indigo-100 text-indigo-800';
case 'Audio':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getPageButtonClass = (page) => {
return page === currentPage.value
? 'relative inline-flex items-center px-4 py-2 border border-blue-500 bg-blue-500 text-sm font-medium text-white'
: 'relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50';
};
const triggerFileInput = () => {
fileInput.value.click();
};
const onDragEnter = (e) => {
isDragging.value = true;
};
const onDragOver = (e) => {
isDragging.value = true;
};
const onDragLeave = (e) => {
isDragging.value = false;
};
const onDrop = (e) => {
isDragging.value = false;
const files = e.dataTransfer.files;
handleFiles(files);
};
const onFileSelected = (e) => {
const files = e.target.files;
handleFiles(files);
};
const handleFiles = (files) => {
const fileNames = Array.from(files).map(file => file.name).join(', ');
alert(`Files selected: ${fileNames}`);
// In a real app, we would upload these files to the server
};
const filterMedia = () => {
currentPage.value = 1; // Reset to first page when filtering
};
const sortBy = (field) => {
if (sortOrder.value.field === field) {
// Toggle direction
sortOrder.value.direction = sortOrder.value.direction === 'asc' ? 'desc' : 'asc';
} else {
// New field, default to ascending
sortOrder.value.field = field;
sortOrder.value.direction = 'asc';
}
};
const prevPage = () => {
if (currentPage.value > 1) {
currentPage.value--;
}
};
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++;
}
};
const goToPage = (page) => {
currentPage.value = page;
};
const previewFile = (file) => {
alert(`Preview file: ${file.fileName}`);
};
const downloadFile = (file) => {
alert(`Download file: ${file.fileName}`);
};
const deleteFile = (file) => {
if (confirm(`Are you sure you want to delete ${file.fileName}?`)) {
// In a real app, we would make an API call to delete the file
mediaFiles.value = mediaFiles.value.filter(f => f.id !== file.id);
alert(`Deleted file: ${file.fileName}`);
}
};
</script>
<style scoped>
.upload-area {
border: 2px dashed #cbd5e0;
transition: all 0.3s ease;
}
.upload-area.active {
border-color: #4299e1;
background-color: rgba(66, 153, 225, 0.1);
}
.table-row {
transition: background-color 0.2s ease;
}
.table-row:hover {
background-color: rgba(237, 242, 247, 0.7);
}
.pagination-item.active {
background-color: #4299e1;
color: white;
}
.media-table th {
position: relative;
}
.media-table th:after {
content: '';
position: absolute;
right: 0;
top: 25%;
height: 50%;
width: 1px;
background-color: #e2e8f0;
}
.media-table th:last-child:after {
display: none;
}
.preview-thumbnail {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
}
</style>

View File

@@ -1,341 +0,0 @@
<template>
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">文章列表</h1>
<div class="flex space-x-2">
<button class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md transition"
@click="createPost">
创建文章
</button>
<div class="relative">
<input type="text" placeholder="搜索文章..."
class="border rounded-md px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
v-model="searchQuery" @input="searchPosts">
<button class="absolute right-2 top-2.5 text-gray-400 hover:text-gray-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<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" />
</svg>
</button>
</div>
</div>
</div>
<div class="bg-white shadow rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
标题
</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
价格
</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
发布时间
</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
发布状态
</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
媒体类型
</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
观看次数
</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="post in displayedPosts" :key="post.id">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10">
<img class="h-10 w-10 rounded-md object-cover" :src="post.thumbnailUrl"
:alt="post.title">
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900">{{ post.title }}</div>
<div class="text-sm text-gray-500">作者: {{ post.author }}</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">¥{{ post.price }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">{{ post.publishTime }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
:class="getStatusClass(post.status)">
{{ post.status }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">{{ post.mediaTypes.join(', ') }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ post.viewCount }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex space-x-2">
<button @click="editPost(post)"
class="text-indigo-600 hover:text-indigo-900">编辑</button>
<button @click="deletePost(post)"
class="text-red-600 hover:text-red-900">删除</button>
<button @click="viewPost(post)"
class="text-gray-600 hover:text-gray-900">查看</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex-1 flex justify-between sm:hidden">
<a href="#"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
@click.prevent="prevPage" :class="{ 'opacity-50 cursor-not-allowed': currentPage === 1 }">
上一页
</a>
<a href="#"
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
@click.prevent="nextPage"
:class="{ 'opacity-50 cursor-not-allowed': currentPage === totalPages }">
下一页
</a>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
显示第 <span class="font-medium">{{ startItem }}</span> <span class="font-medium">{{
endItem }}</span> <span class="font-medium">{{ totalItems }}</span> 条结果
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination">
<a href="#"
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
@click.prevent="prevPage"
:class="{ 'opacity-50 cursor-not-allowed': currentPage === 1 }">
<span class="sr-only">上一页</span>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
</a>
<template v-for="page in paginationItems" :key="page">
<a v-if="page !== '...'" href="#"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium hover:bg-gray-50"
:class="page === currentPage ? 'text-blue-600' : 'text-gray-700'"
@click.prevent="goToPage(page)">
{{ page }}
</a>
<span v-else
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
...
</span>
</template>
<a href="#"
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
@click.prevent="nextPage"
:class="{ 'opacity-50 cursor-not-allowed': currentPage === totalPages }">
<span class="sr-only">下一页</span>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd" />
</svg>
</a>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
// Reactive state
const posts = ref([
{
id: 1,
title: '如何高效学习编程',
author: '张三',
thumbnailUrl: 'https://via.placeholder.com/150',
price: '29.99',
publishTime: '2023-06-15 14:30',
status: '已发布',
mediaTypes: ['文章', '视频'],
viewCount: 1254
},
{
id: 2,
title: '前端开发最佳实践',
author: '李四',
thumbnailUrl: 'https://via.placeholder.com/150',
price: '49.99',
publishTime: '2023-06-10 09:15',
status: '草稿',
mediaTypes: ['文章'],
viewCount: 789
},
{
id: 3,
title: '数据分析入门指南',
author: '王五',
thumbnailUrl: 'https://via.placeholder.com/150',
price: '0.00',
publishTime: '2023-06-05 16:45',
status: '已下架',
mediaTypes: ['文章', '音频'],
viewCount: 2567
}
]);
const searchQuery = ref('');
const currentPage = ref(3);
const itemsPerPage = ref(10);
const totalItems = ref(50);
// Computed properties
const filteredPosts = computed(() => {
if (!searchQuery.value) {
return posts.value;
}
const query = searchQuery.value.toLowerCase();
return posts.value.filter(post =>
post.title.toLowerCase().includes(query) ||
post.author.toLowerCase().includes(query)
);
});
const displayedPosts = computed(() => {
return filteredPosts.value;
});
const totalPages = computed(() => {
return Math.ceil(totalItems.value / itemsPerPage.value);
});
const startItem = computed(() => {
return (currentPage.value - 1) * itemsPerPage.value + 1;
});
const endItem = computed(() => {
const end = currentPage.value * itemsPerPage.value;
return end > totalItems.value ? totalItems.value : end;
});
const paginationItems = computed(() => {
const items = [];
const maxPagesToShow = 5;
if (totalPages.value <= maxPagesToShow) {
// Show all pages
for (let i = 1; i <= totalPages.value; i++) {
items.push(i);
}
} else {
// Show limited pages with ellipsis
if (currentPage.value <= 3) {
// Current page is near the start
for (let i = 1; i <= 3; i++) {
items.push(i);
}
items.push('...');
items.push(totalPages.value);
} else if (currentPage.value >= totalPages.value - 2) {
// Current page is near the end
items.push(1);
items.push('...');
for (let i = totalPages.value - 2; i <= totalPages.value; i++) {
items.push(i);
}
} else {
// Current page is in the middle
items.push(1);
items.push('...');
items.push(currentPage.value - 1);
items.push(currentPage.value);
items.push(currentPage.value + 1);
items.push('...');
items.push(totalPages.value);
}
}
return items;
});
// Methods
const getStatusClass = (status) => {
switch (status) {
case '已发布':
return 'bg-green-100 text-green-800';
case '草稿':
return 'bg-yellow-100 text-yellow-800';
case '已下架':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const createPost = () => {
alert('创建新文章功能待实现');
};
const editPost = (post) => {
alert(`编辑文章: ${post.title}`);
};
const deletePost = (post) => {
if (confirm(`确定要删除文章 "${post.title}" 吗?`)) {
// In a real app, we would make an API call to delete the post
alert(`已删除文章: ${post.title}`);
}
};
const viewPost = (post) => {
alert(`查看文章: ${post.title}`);
};
const searchPosts = () => {
currentPage.value = 1; // Reset to first page when searching
};
const prevPage = () => {
if (currentPage.value > 1) {
currentPage.value--;
}
};
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++;
}
};
const goToPage = (page) => {
currentPage.value = page;
};
</script>