feat: add prime vue
This commit is contained in:
@@ -4,10 +4,14 @@
|
||||
"": {
|
||||
"name": "admin",
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.0.0",
|
||||
"@tailwindcss/vite": "^4.0.17",
|
||||
"axios": "^1.8.4",
|
||||
"daisyui": "^5.0.9",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.3",
|
||||
"tailwindcss": "^4.0.17",
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "4",
|
||||
},
|
||||
@@ -78,6 +82,18 @@
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@primeuix/styled": ["@primeuix/styled@0.5.0", "https://registry.npmmirror.com/@primeuix/styled/-/styled-0.5.0.tgz", { "dependencies": { "@primeuix/utils": "^0.5.0" } }, "sha512-k5CTQ+10cXIXxZTep7sktmYe8lJkjmUaFVDAc1OCsWTJR+bhBy/s6zWIatGljVtuf3RmTSxtlrHQeFLjPmdUNQ=="],
|
||||
|
||||
"@primeuix/styles": ["@primeuix/styles@1.0.0", "https://registry.npmmirror.com/@primeuix/styles/-/styles-1.0.0.tgz", { "dependencies": { "@primeuix/styled": "^0.5.0" } }, "sha512-j/TlbqihLNMP37zFNjxac5dTRaQEf5Ldrv0P7NwKigCCc/+MI5j4MddxDw1LnxkGhWCJ1Gjbt9uwyQteWtSv7A=="],
|
||||
|
||||
"@primeuix/themes": ["@primeuix/themes@1.0.0", "https://registry.npmmirror.com/@primeuix/themes/-/themes-1.0.0.tgz", { "dependencies": { "@primeuix/styled": "^0.5.0" } }, "sha512-fxUgcAP9H6FeytbE8c4QvRt8aBnoyZJqvtnnVwHT8PHr1dNSnC1nYKGrXpebcx3SpNy9Hp9oVidGsl6u61+pXQ=="],
|
||||
|
||||
"@primeuix/utils": ["@primeuix/utils@0.5.2", "https://registry.npmmirror.com/@primeuix/utils/-/utils-0.5.2.tgz", {}, "sha512-fHL0DGnyhL/9toBoV0cO6L+Xg/uaxmOHJW4SrDNMq6GQ7cDXR3Y0vDLvX5j/8kIsaC7LB3329sQLNHgtEETnWw=="],
|
||||
|
||||
"@primevue/core": ["@primevue/core@4.3.3", "https://registry.npmmirror.com/@primevue/core/-/core-4.3.3.tgz", { "dependencies": { "@primeuix/styled": "^0.5.0", "@primeuix/utils": "^0.5.1" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-kSkN5oourG7eueoFPIqiNX3oDT/f0I5IRK3uOY/ytz+VzTZp5yuaCN0Nt42ZQpVXjDxMxDvUhIdaXVrjr58NhQ=="],
|
||||
|
||||
"@primevue/icons": ["@primevue/icons@4.3.3", "https://registry.npmmirror.com/@primevue/icons/-/icons-4.3.3.tgz", { "dependencies": { "@primeuix/utils": "^0.5.1", "@primevue/core": "4.3.3" } }, "sha512-ouQaxHyeFB6MSfEGGbjaK5Qv9efS1xZGetZoU5jcPm090MSYLFtroP1CuK3lZZAQals06TZ6T6qcoNukSHpK5w=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.37.0", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.37.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-l7StVw6WAa8l3vA1ov80jyetOAEo1FtHvZDbzXDO/02Sq/QVvqlHkYoFwDJPIMj0GKiistsBudfx5tGFnwYWDQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.37.0", "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.37.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-6U3SlVyMxezt8Y+/iEBcbp945uZjJwjZimu76xoG7tO1av9VO691z8PkhzQ85ith2I8R2RddEPeSfcbyPfD4hA=="],
|
||||
@@ -264,6 +280,10 @@
|
||||
|
||||
"postcss": ["postcss@8.5.3", "https://registry.npmmirror.com/postcss/-/postcss-8.5.3.tgz", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
|
||||
|
||||
"primeicons": ["primeicons@7.0.0", "https://registry.npmmirror.com/primeicons/-/primeicons-7.0.0.tgz", {}, "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw=="],
|
||||
|
||||
"primevue": ["primevue@4.3.3", "https://registry.npmmirror.com/primevue/-/primevue-4.3.3.tgz", { "dependencies": { "@primeuix/styled": "^0.5.0", "@primeuix/styles": "^1.0.0", "@primeuix/utils": "^0.5.1", "@primevue/core": "4.3.3", "@primevue/icons": "4.3.3" } }, "sha512-nooYVoEz5CdP3EhUkD6c3qTdRmpLHZh75fBynkUkl46K8y5rksHTjdSISiDijwTA5STQIOkyqLb+RM+HQ6nC1Q=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"rollup": ["rollup@4.37.0", "https://registry.npmmirror.com/rollup/-/rollup-4.37.0.tgz", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.37.0", "@rollup/rollup-android-arm64": "4.37.0", "@rollup/rollup-darwin-arm64": "4.37.0", "@rollup/rollup-darwin-x64": "4.37.0", "@rollup/rollup-freebsd-arm64": "4.37.0", "@rollup/rollup-freebsd-x64": "4.37.0", "@rollup/rollup-linux-arm-gnueabihf": "4.37.0", "@rollup/rollup-linux-arm-musleabihf": "4.37.0", "@rollup/rollup-linux-arm64-gnu": "4.37.0", "@rollup/rollup-linux-arm64-musl": "4.37.0", "@rollup/rollup-linux-loongarch64-gnu": "4.37.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.37.0", "@rollup/rollup-linux-riscv64-gnu": "4.37.0", "@rollup/rollup-linux-riscv64-musl": "4.37.0", "@rollup/rollup-linux-s390x-gnu": "4.37.0", "@rollup/rollup-linux-x64-gnu": "4.37.0", "@rollup/rollup-linux-x64-musl": "4.37.0", "@rollup/rollup-win32-arm64-msvc": "4.37.0", "@rollup/rollup-win32-ia32-msvc": "4.37.0", "@rollup/rollup-win32-x64-msvc": "4.37.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-iAtQy/L4QFU+rTJ1YUjXqJOJzuwEghqWzCEYD2FEghT7Gsy1VdABntrO4CLopA5IkflTyqNiLNwPcOJ3S7UKLg=="],
|
||||
@@ -272,6 +292,8 @@
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.0.17", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.0.17.tgz", {}, "sha512-OErSiGzRa6rLiOvaipsDZvLMSpsBZ4ysB4f0VKGXUrjw2jfkJRd6kjRKV2+ZmTCNvwtvgdDam5D7w6WXsdLJZw=="],
|
||||
|
||||
"tailwindcss-primeui": ["tailwindcss-primeui@0.6.1", "https://registry.npmmirror.com/tailwindcss-primeui/-/tailwindcss-primeui-0.6.1.tgz", { "peerDependencies": { "tailwindcss": ">=3.1.0" } }, "sha512-T69Rylcrmnt8zy9ik+qZvsLuRIrS9/k6rYJSIgZ1trnbEzGDDQSCIdmfyZknevqiHwpSJHSmQ9XT2C+S/hJY4A=="],
|
||||
|
||||
"tapable": ["tapable@2.2.1", "https://registry.npmmirror.com/tapable/-/tapable-2.2.1.tgz", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="],
|
||||
|
||||
"vite": ["vite@6.2.2", "https://registry.npmmirror.com/vite/-/vite-6.2.2.tgz", { "dependencies": { "esbuild": "^0.25.0", "postcss": "^8.5.3", "rollup": "^4.30.1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-yW7PeMM+LkDzc7CgJuRLMW2Jz0FxMOsVJ8Lv3gpgW9WLcb9cTW+121UEr1hvmfR7w3SegR5ItvYyzVz1vxNJgQ=="],
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.0.0",
|
||||
"@tailwindcss/vite": "^4.0.17",
|
||||
"axios": "^1.8.4",
|
||||
"daisyui": "^5.0.9",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.3",
|
||||
"tailwindcss": "^4.0.17",
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "4"
|
||||
},
|
||||
|
||||
@@ -1,42 +1,53 @@
|
||||
<script setup>
|
||||
// 不再需要下拉菜单的状态,可以删除相关代码
|
||||
import Button from 'primevue/button';
|
||||
import Menubar from 'primevue/menubar';
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const navItems = ref([
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon: 'pi pi-home',
|
||||
command: () => router.push('/')
|
||||
},
|
||||
{
|
||||
label: 'Media',
|
||||
icon: 'pi pi-image',
|
||||
command: () => router.push('/media')
|
||||
},
|
||||
{
|
||||
label: 'Articles',
|
||||
icon: 'pi pi-file',
|
||||
command: () => router.push('/articles')
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: 'pi pi-cog',
|
||||
command: () => router.push('/settings')
|
||||
}
|
||||
]);
|
||||
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<header class="sticky top-0 z-50 shadow-md">
|
||||
<Menubar :model="navItems" class="!rounded-none">
|
||||
<template #start>
|
||||
<div class="flex items-center pr-4">
|
||||
<h2 class="m-0 text-2xl text-primary font-semibold">Admin Panel</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template #end>
|
||||
<Button label="Logout" icon="pi pi-power-off" severity="secondary" text />
|
||||
</template>
|
||||
</Menubar>
|
||||
</header>
|
||||
|
||||
<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 class="flex-1 p-6 bg-surface-ground">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App'
|
||||
}
|
||||
</script>
|
||||
45
frontend/admin/src/api/apiClient.js
Normal file
45
frontend/admin/src/api/apiClient.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Create a base axios instance
|
||||
export const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// You can add auth tokens here if needed
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
// Handle common errors (e.g., unauthorized, server errors)
|
||||
if (error.response) {
|
||||
// Handle specific status codes
|
||||
if (error.response.status === 401) {
|
||||
// Handle unauthorized
|
||||
console.error('Unauthorized request');
|
||||
// You might want to redirect to login page
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
132
frontend/admin/src/api/statsApi.js
Normal file
132
frontend/admin/src/api/statsApi.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import { apiClient } from './apiClient';
|
||||
|
||||
// Environment detection
|
||||
let isDevelopment = false; // 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';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error detecting environment:', error);
|
||||
}
|
||||
setTimeout(() => {
|
||||
console.log('%cCurrent environment: ' + (isDevelopment ? 'DEVELOPMENT' : 'PRODUCTION'),
|
||||
'background: #222; color: #bada55; font-size: 16px; padding: 4px;');
|
||||
}, 0);
|
||||
|
||||
// Mock service implementation
|
||||
const mockService = {
|
||||
/**
|
||||
* Mock implementation for getting media count
|
||||
* @returns {Promise<{count: number}>}
|
||||
*/
|
||||
getMediaCount: async () => {
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
// Return mock data
|
||||
return {
|
||||
data: {
|
||||
count: Math.floor(Math.random() * 500) + 100 // Random count between 100-600
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Mock implementation for getting article count
|
||||
* @returns {Promise<{count: number}>}
|
||||
*/
|
||||
getArticleCount: async () => {
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
|
||||
// Return mock data
|
||||
return {
|
||||
data: {
|
||||
count: Math.floor(Math.random() * 200) + 50 // Random count between 50-250
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Real API implementation
|
||||
const realApiService = {
|
||||
/**
|
||||
* Real implementation for getting media count
|
||||
* @returns {Promise<{count: number}>}
|
||||
*/
|
||||
getMediaCount: async () => {
|
||||
try {
|
||||
return await apiClient.get('/api/media/count');
|
||||
} catch (error) {
|
||||
console.error('Error fetching media count:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Real implementation for getting article count
|
||||
* @returns {Promise<{count: number}>}
|
||||
*/
|
||||
getArticleCount: async () => {
|
||||
try {
|
||||
return await apiClient.get('/api/articles/count');
|
||||
} catch (error) {
|
||||
console.error('Error fetching article count:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Log which service we're using
|
||||
console.log(`Using ${isDevelopment ? 'MOCK' : 'REAL'} API service for stats`);
|
||||
|
||||
// Use the appropriate service based on environment
|
||||
const apiService = isDevelopment ? mockService : realApiService;
|
||||
|
||||
export const statsApi = {
|
||||
/**
|
||||
* Get the total count of media items
|
||||
* @returns {Promise<{count: number}>} The media count
|
||||
*/
|
||||
getMediaCount: async () => {
|
||||
const response = await apiService.getMediaCount();
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the total count of articles
|
||||
* @returns {Promise<{count: number}>} The article count
|
||||
*/
|
||||
getArticleCount: async () => {
|
||||
const response = await apiService.getArticleCount();
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all statistics in a single call
|
||||
* @returns {Promise<{mediaCount: number, articleCount: number}>}
|
||||
*/
|
||||
getAllStats: async () => {
|
||||
try {
|
||||
const [mediaResponse, articleResponse] = await Promise.all([
|
||||
apiService.getMediaCount(),
|
||||
apiService.getArticleCount()
|
||||
]);
|
||||
|
||||
return {
|
||||
mediaCount: mediaResponse.data.count,
|
||||
articleCount: articleResponse.data.count
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching all stats:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
msg: String,
|
||||
})
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,11 @@
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import PrimeVue from 'primevue/config';
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import { router } from './router.js';
|
||||
|
||||
// Import only the required PrimeVue styles
|
||||
import 'primeicons/primeicons.css'; // Icons
|
||||
import './style.css';
|
||||
|
||||
// Log environment information during app initialization
|
||||
@@ -13,4 +18,18 @@ if (typeof import.meta !== 'undefined') {
|
||||
}
|
||||
console.log('=============================');
|
||||
|
||||
createApp(App).use(router).mount('#app');
|
||||
const app = createApp(App)
|
||||
app.use(router);
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
},
|
||||
ripple: true,
|
||||
options: {
|
||||
darkModeSelector: '.my-app-dark',
|
||||
}
|
||||
})
|
||||
|
||||
// Remove global component registrations
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -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>
|
||||
@@ -7,16 +7,6 @@ const routes = [
|
||||
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
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
@import "tailwindcss-primeui";
|
||||
|
||||
@custom-variant dark (&:where(.my-app-dark, .my-app-dark *));
|
||||
Reference in New Issue
Block a user