feat: update backend
This commit is contained in:
@@ -1,9 +1,42 @@
|
||||
<script setup>
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
// 不再需要下拉菜单的状态,可以删除相关代码
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="btn">Button</button>
|
||||
<button class="btn btn-primary">Button</button>
|
||||
<button class="btn w-64 rounded-full">Button</button>
|
||||
</template>
|
||||
<div class="min-h-screen bg-base-200">
|
||||
<div class="navbar bg-base-100 shadow-md">
|
||||
<div class="navbar-start">
|
||||
<div class="px-2 mx-2">
|
||||
<h1 class="text-xl font-bold">Hello</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-center">
|
||||
<div class="tabs tabs-boxed bg-base-100">
|
||||
<router-link to="/posts" class="tab" :class="{ 'tab-active': $route.path === '/posts' }">
|
||||
文章管理
|
||||
</router-link>
|
||||
<router-link to="/medias" class="tab" :class="{ 'tab-active': $route.path === '/medias' }">
|
||||
媒体库
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<button class="btn btn-error btn-sm">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="container mx-auto py-6 px-4">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App'
|
||||
}
|
||||
</script>
|
||||
48
frontend/admin/src/api/httpClient.js
Normal file
48
frontend/admin/src/api/httpClient.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Create axios instance with default config
|
||||
const httpClient = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
httpClient.interceptors.request.use(
|
||||
config => {
|
||||
// You can add auth token here if needed
|
||||
// const token = localStorage.getItem('token');
|
||||
// if (token) {
|
||||
// config.headers.Authorization = `Bearer ${token}`;
|
||||
// }
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
httpClient.interceptors.response.use(
|
||||
response => {
|
||||
return response.data;
|
||||
},
|
||||
error => {
|
||||
// Handle HTTP errors here
|
||||
if (error.response) {
|
||||
// Server responded with error status
|
||||
console.error('API Error:', error.response.status, error.response.data);
|
||||
} else if (error.request) {
|
||||
// Request made but no response received
|
||||
console.error('API Error: No response received', error.request);
|
||||
} else {
|
||||
// Something else happened
|
||||
console.error('API Error:', error.message);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default httpClient;
|
||||
31
frontend/admin/src/api/mockService.js
Normal file
31
frontend/admin/src/api/mockService.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Mock service that provides fake data for development
|
||||
*/
|
||||
export const mockService = {
|
||||
/**
|
||||
* Get mock count of posts
|
||||
* @returns {Promise<{count: number}>}
|
||||
*/
|
||||
getPostsCount() {
|
||||
console.log('Using MOCK service for posts count');
|
||||
return Promise.resolve({ count: 42 });
|
||||
},
|
||||
|
||||
/**
|
||||
* Get mock count of media items
|
||||
* @returns {Promise<{count: number}>}
|
||||
*/
|
||||
getMediasCount() {
|
||||
console.log('Using MOCK service for medias count');
|
||||
return Promise.resolve({ count: 18 });
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all mock statistics in a single call
|
||||
* @returns {Promise<{posts: number, medias: number}>}
|
||||
*/
|
||||
getAllStatistics() {
|
||||
console.log('Using MOCK service for all statistics');
|
||||
return Promise.resolve({ posts: 42, medias: 18 });
|
||||
}
|
||||
};
|
||||
67
frontend/admin/src/api/statisticsService.js
Normal file
67
frontend/admin/src/api/statisticsService.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import httpClient from './httpClient';
|
||||
import { mockService } from './mockService';
|
||||
|
||||
// Simplify environment detection and ensure the console log works
|
||||
let isDevelopment = true; // Default to development mode
|
||||
|
||||
// Try different ways to detect environment
|
||||
try {
|
||||
if (typeof process !== 'undefined' && process.env) {
|
||||
console.log('Detected process.env, NODE_ENV:', process.env.NODE_ENV);
|
||||
isDevelopment = process.env.NODE_ENV === 'development';
|
||||
} else if (typeof import.meta !== 'undefined' && import.meta.env) {
|
||||
console.log('Detected import.meta.env, MODE:', import.meta.env.MODE);
|
||||
isDevelopment = import.meta.env.MODE === 'development';
|
||||
} else {
|
||||
console.log('No environment variables detected, defaulting to development mode');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error detecting environment:', error);
|
||||
}
|
||||
|
||||
// Force console log with timeout to ensure it runs after other initialization
|
||||
setTimeout(() => {
|
||||
console.log('%cCurrent environment: ' + (isDevelopment ? 'DEVELOPMENT' : 'PRODUCTION'),
|
||||
'background: #222; color: #bada55; font-size: 16px; padding: 4px;');
|
||||
}, 0);
|
||||
|
||||
// Use the appropriate service based on environment
|
||||
const apiService = isDevelopment ? mockService : {
|
||||
getPostsCount() {
|
||||
return httpClient.get('/posts/count');
|
||||
},
|
||||
|
||||
getMediasCount() {
|
||||
return httpClient.get('/medias/count');
|
||||
},
|
||||
|
||||
getAllStatistics() {
|
||||
return httpClient.get('/statistics');
|
||||
}
|
||||
};
|
||||
|
||||
export const statisticsService = {
|
||||
/**
|
||||
* Get count of posts
|
||||
* @returns {Promise<{count: number}>}
|
||||
*/
|
||||
getPostsCount() {
|
||||
return apiService.getPostsCount();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get count of media items
|
||||
* @returns {Promise<{count: number}>}
|
||||
*/
|
||||
getMediasCount() {
|
||||
return apiService.getMediasCount();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all statistics in a single call
|
||||
* @returns {Promise<{posts: number, medias: number}>}
|
||||
*/
|
||||
getAllStatistics() {
|
||||
return apiService.getAllStatistics();
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,16 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app')
|
||||
// Log environment information during app initialization
|
||||
console.log('=== Environment Information ===');
|
||||
console.log('NODE_ENV:', process.env.NODE_ENV);
|
||||
if (typeof import.meta !== 'undefined') {
|
||||
console.log('Vite MODE:', import.meta.env.MODE);
|
||||
console.log('Vite DEV:', import.meta.env.DEV);
|
||||
console.log('Vite PROD:', import.meta.env.PROD);
|
||||
}
|
||||
console.log('=============================');
|
||||
|
||||
createApp(App).use(router).mount('#app');
|
||||
|
||||
135
frontend/admin/src/pages/HomePage.vue
Normal file
135
frontend/admin/src/pages/HomePage.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<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 { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { statisticsService } from '../api/statisticsService';
|
||||
|
||||
const router = useRouter();
|
||||
const postCount = ref(0);
|
||||
const mediaCount = ref(0);
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
// Option 1: Make parallel requests using our service
|
||||
const [postsData, mediasData] = await Promise.all([
|
||||
statisticsService.getPostsCount(),
|
||||
statisticsService.getMediasCount()
|
||||
]);
|
||||
|
||||
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;
|
||||
} catch (err) {
|
||||
console.error('Error fetching statistics:', err);
|
||||
error.value = '获取统计数据时出错';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const navigateTo = (path) => {
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.statistics-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.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>
|
||||
481
frontend/admin/src/pages/MediasPage.vue
Normal file
481
frontend/admin/src/pages/MediasPage.vue
Normal file
@@ -0,0 +1,481 @@
|
||||
<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>
|
||||
341
frontend/admin/src/pages/PostsPage.vue
Normal file
341
frontend/admin/src/pages/PostsPage.vue
Normal file
@@ -0,0 +1,341 @@
|
||||
<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>
|
||||
26
frontend/admin/src/router.js
Normal file
26
frontend/admin/src/router.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
// Define your routes here
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('./pages/HomePage.vue')
|
||||
},
|
||||
{
|
||||
path: '/posts',
|
||||
name: 'Posts',
|
||||
component: () => import('./pages/PostsPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/medias',
|
||||
name: 'Medias',
|
||||
component: () => import('./pages/MediasPage.vue')
|
||||
}
|
||||
];
|
||||
|
||||
// Create the router instance
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
Reference in New Issue
Block a user