feat: add prime vue
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user