feat: add edit page
This commit is contained in:
@@ -20,7 +20,7 @@ const navItems = ref([
|
||||
{
|
||||
label: 'Articles',
|
||||
icon: 'pi pi-file',
|
||||
command: () => router.push('/articles')
|
||||
command: () => router.push('/posts')
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
|
||||
363
frontend/admin/src/pages/PostCreatePage.vue
Normal file
363
frontend/admin/src/pages/PostCreatePage.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<script setup>
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
// PrimeVue components
|
||||
import Badge from 'primevue/badge';
|
||||
import Button from 'primevue/button';
|
||||
import Card from 'primevue/card';
|
||||
import Column from 'primevue/column';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Toast from 'primevue/toast';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
// Form state
|
||||
const post = reactive({
|
||||
title: '',
|
||||
price: 0,
|
||||
introduction: '',
|
||||
selectedMedia: []
|
||||
});
|
||||
|
||||
// Validation state
|
||||
const errors = reactive({
|
||||
title: '',
|
||||
introduction: '',
|
||||
selectedMedia: ''
|
||||
});
|
||||
|
||||
// Media selection dialog state
|
||||
const mediaDialogVisible = ref(false);
|
||||
const selectedMediaItems = ref([]);
|
||||
const mediaLoading = ref(false);
|
||||
const mediaGlobalFilter = ref('');
|
||||
|
||||
// Sample media data - in a real app, this would come from an API
|
||||
const mediaItems = ref([
|
||||
{
|
||||
id: 1,
|
||||
fileName: 'sunset-beach.jpg',
|
||||
fileType: 'Image',
|
||||
thumbnailUrl: 'https://via.placeholder.com/300x225',
|
||||
fileSize: '2.4 MB',
|
||||
uploadTime: 'Today, 10:30 AM'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
fileName: 'presentation.pdf',
|
||||
fileType: 'PDF',
|
||||
thumbnailUrl: null,
|
||||
fileSize: '4.8 MB',
|
||||
uploadTime: 'Yesterday, 3:45 PM'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
fileName: 'promo_video.mp4',
|
||||
fileType: 'Video',
|
||||
thumbnailUrl: null,
|
||||
fileSize: '24.8 MB',
|
||||
uploadTime: 'Aug 28, 2023'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
fileName: 'report_q3.docx',
|
||||
fileType: 'Document',
|
||||
thumbnailUrl: null,
|
||||
fileSize: '1.2 MB',
|
||||
uploadTime: 'Aug 25, 2023'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
fileName: 'podcast_interview.mp3',
|
||||
fileType: 'Audio',
|
||||
thumbnailUrl: null,
|
||||
fileSize: '18.5 MB',
|
||||
uploadTime: 'Aug 20, 2023'
|
||||
}
|
||||
]);
|
||||
|
||||
// Open media selection dialog
|
||||
const openMediaDialog = () => {
|
||||
mediaDialogVisible.value = true;
|
||||
loadMediaItems();
|
||||
};
|
||||
|
||||
// Load media items
|
||||
const loadMediaItems = async () => {
|
||||
mediaLoading.value = true;
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await mediaApi.getMediaFiles();
|
||||
// mediaItems.value = response.data;
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
// Using sample data already defined above
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '加载媒体文件失败', life: 3000 });
|
||||
} finally {
|
||||
mediaLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Confirm media selection
|
||||
const confirmMediaSelection = () => {
|
||||
if (selectedMediaItems.value.length) {
|
||||
post.selectedMedia = [...selectedMediaItems.value];
|
||||
errors.selectedMedia = '';
|
||||
}
|
||||
mediaDialogVisible.value = false;
|
||||
};
|
||||
|
||||
// Cancel media selection
|
||||
const cancelMediaSelection = () => {
|
||||
mediaDialogVisible.value = false;
|
||||
};
|
||||
|
||||
// Remove a selected media item
|
||||
const removeMedia = (media) => {
|
||||
const index = post.selectedMedia.findIndex(item => item.id === media.id);
|
||||
if (index > -1) {
|
||||
post.selectedMedia.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Save the post
|
||||
const savePost = async () => {
|
||||
// Reset errors
|
||||
Object.keys(errors).forEach(key => errors[key] = '');
|
||||
|
||||
// Validate form
|
||||
let valid = true;
|
||||
|
||||
if (!post.title.trim()) {
|
||||
errors.title = '请填写文章标题';
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!post.introduction.trim()) {
|
||||
errors.introduction = '请填写文章介绍';
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (post.selectedMedia.length === 0) {
|
||||
errors.selectedMedia = '请选择至少一个媒体文件';
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
toast.add({ severity: 'error', summary: '表单错误', detail: '请检查表单中的错误并修正', life: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// In a real app, you would call an API to save the post
|
||||
// await postApi.createPost(post);
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
toast.add({ severity: 'success', summary: '成功', detail: '文章已成功创建', life: 3000 });
|
||||
|
||||
// Navigate back to the post list
|
||||
router.push('/posts');
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '创建文章失败', life: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel and go back to post list
|
||||
const cancelCreate = () => {
|
||||
router.push('/posts');
|
||||
};
|
||||
|
||||
// File type badge severity mapping
|
||||
const getBadgeSeverity = (fileType) => {
|
||||
const map = {
|
||||
'Image': 'info',
|
||||
'PDF': 'danger',
|
||||
'Video': 'warning',
|
||||
'Document': 'primary',
|
||||
'Audio': 'success'
|
||||
};
|
||||
return map[fileType] || 'info';
|
||||
};
|
||||
|
||||
// File type icon mapping
|
||||
const getFileIcon = (file) => {
|
||||
const map = {
|
||||
'Image': 'pi-image',
|
||||
'PDF': 'pi-file-pdf',
|
||||
'Video': 'pi-video',
|
||||
'Document': 'pi-file',
|
||||
'Audio': 'pi-volume-up'
|
||||
};
|
||||
return `pi ${map[file.fileType] || 'pi-file'}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<div class="w-full max-w-6xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">创建文章</h1>
|
||||
<div class="flex gap-2">
|
||||
<Button label="取消" icon="pi pi-times" severity="secondary" @click="cancelCreate" />
|
||||
<Button label="保存" icon="pi pi-check" severity="primary" @click="savePost" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card class="mb-6">
|
||||
<template #content>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Title -->
|
||||
<div class="col-span-2">
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">标题</label>
|
||||
<InputText id="title" v-model="post.title" class="w-full p-inputtext-lg" placeholder="输入文章标题" />
|
||||
<small v-if="errors.title" class="p-error">{{ errors.title }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="col-span-1">
|
||||
<label for="price" class="block text-sm font-medium text-gray-700 mb-1">价格 (¥)</label>
|
||||
<InputNumber id="price" v-model="post.price" mode="currency" currency="CNY"
|
||||
:minFractionDigits="2" class="w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Introduction -->
|
||||
<div class="col-span-2">
|
||||
<label for="introduction" class="block text-sm font-medium text-gray-700 mb-1">文章介绍</label>
|
||||
<Textarea id="introduction" v-model="post.introduction" rows="5" class="w-full"
|
||||
placeholder="输入文章介绍内容" />
|
||||
<small v-if="errors.introduction" class="p-error">{{ errors.introduction }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Media Selection -->
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">媒体资源</label>
|
||||
<div class="p-4 border border-gray-200 rounded-md">
|
||||
<div v-if="post.selectedMedia.length === 0"
|
||||
class="flex justify-center items-center flex-col space-y-3 py-6">
|
||||
<i class="pi pi-image text-gray-400 text-3xl"></i>
|
||||
<p class="text-gray-500">尚未选择任何媒体文件</p>
|
||||
<Button label="选择媒体" icon="pi pi-plus" @click="openMediaDialog" outlined />
|
||||
<small v-if="errors.selectedMedia" class="p-error">{{ errors.selectedMedia }}</small>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="mb-4">
|
||||
<Button label="添加更多媒体" icon="pi pi-plus" @click="openMediaDialog" outlined />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div v-for="media in post.selectedMedia" :key="media.id"
|
||||
class="relative border border-gray-200 rounded-md p-2 flex items-center">
|
||||
<div v-if="media.thumbnailUrl" class="flex-shrink-0 h-10 w-10 mr-3">
|
||||
<img class="h-10 w-10 object-cover rounded" :src="media.thumbnailUrl"
|
||||
:alt="media.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="getFileIcon(media)" class="text-2xl"></i>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="text-sm font-medium text-gray-900 truncate">{{ media.fileName }}
|
||||
</div>
|
||||
<Badge :value="media.fileType" :severity="getBadgeSeverity(media.fileType)"
|
||||
class="text-xs" />
|
||||
</div>
|
||||
<Button icon="pi pi-times" class="p-button-rounded p-button-text p-button-sm"
|
||||
@click="removeMedia(media)" aria-label="移除" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Media Selection Dialog -->
|
||||
<Dialog v-model:visible="mediaDialogVisible" header="选择媒体" :modal="true" :dismissableMask="true" :closable="true"
|
||||
:style="{ width: '80vw' }" :breakpoints="{ '960px': '90vw' }">
|
||||
<div class="mb-4">
|
||||
<InputText v-model="mediaGlobalFilter" placeholder="搜索媒体..." class="w-full" />
|
||||
</div>
|
||||
|
||||
<DataTable v-model:selection="selectedMediaItems" :value="mediaItems" :loading="mediaLoading" dataKey="id"
|
||||
selectionMode="multiple" :globalFilterFields="['fileName', 'fileType']"
|
||||
:filters="{ global: { value: mediaGlobalFilter, matchMode: 'contains' } }" stripedRows
|
||||
responsiveLayout="scroll">
|
||||
|
||||
<template #empty>
|
||||
<div class="text-center p-4">没有可用的媒体文件</div>
|
||||
</template>
|
||||
|
||||
<template #loading>
|
||||
<div class="flex flex-col items-center justify-center p-4">
|
||||
<ProgressSpinner style="width:50px;height:50px" />
|
||||
<span class="mt-2">加载媒体数据...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column selectionMode="multiple" style="width: 3rem"></Column>
|
||||
|
||||
<Column field="fileName" header="文件名">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center">
|
||||
<div v-if="data.thumbnailUrl" class="flex-shrink-0 h-10 w-10 mr-3">
|
||||
<img class="h-10 w-10 object-cover rounded" :src="data.thumbnailUrl" :alt="data.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="getFileIcon(data)" class="text-2xl"></i>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-900">{{ data.fileName }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="fileType" header="文件类型">
|
||||
<template #body="{ data }">
|
||||
<Badge :value="data.fileType" :severity="getBadgeSeverity(data.fileType)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="fileSize" header="文件大小"></Column>
|
||||
<Column field="uploadTime" header="上传时间"></Column>
|
||||
</DataTable>
|
||||
|
||||
<template #footer>
|
||||
<Button label="取消" icon="pi pi-times" @click="cancelMediaSelection" class="p-button-text" />
|
||||
<Button label="确认选择" icon="pi pi-check" @click="confirmMediaSelection"
|
||||
:disabled="selectedMediaItems.length === 0" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.p-card .p-card-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.p-inputtext-lg {
|
||||
font-size: 1.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.p-inputnumber-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.p-button-sm {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
480
frontend/admin/src/pages/PostEditPage.vue
Normal file
480
frontend/admin/src/pages/PostEditPage.vue
Normal file
@@ -0,0 +1,480 @@
|
||||
<script setup>
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
// PrimeVue components
|
||||
import Badge from 'primevue/badge';
|
||||
import Button from 'primevue/button';
|
||||
import Card from 'primevue/card';
|
||||
import Column from 'primevue/column';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Toast from 'primevue/toast';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
const loading = ref(true);
|
||||
|
||||
// Form state
|
||||
const post = reactive({
|
||||
id: null,
|
||||
title: '',
|
||||
price: 0,
|
||||
introduction: '',
|
||||
status: '',
|
||||
selectedMedia: [],
|
||||
author: '',
|
||||
publishedAt: '',
|
||||
viewCount: 0,
|
||||
mediaTypes: [],
|
||||
});
|
||||
|
||||
// Status options
|
||||
const statusOptions = ref(['已发布', '草稿', '已下架']);
|
||||
|
||||
// Validation state
|
||||
const errors = reactive({
|
||||
title: '',
|
||||
introduction: '',
|
||||
selectedMedia: ''
|
||||
});
|
||||
|
||||
// Media selection dialog state
|
||||
const mediaDialogVisible = ref(false);
|
||||
const selectedMediaItems = ref([]);
|
||||
const mediaLoading = ref(false);
|
||||
const mediaGlobalFilter = ref('');
|
||||
|
||||
// Sample media data - in a real app, this would come from an API
|
||||
const mediaItems = ref([
|
||||
{
|
||||
id: 1,
|
||||
fileName: 'sunset-beach.jpg',
|
||||
fileType: 'Image',
|
||||
thumbnailUrl: 'https://via.placeholder.com/300x225',
|
||||
fileSize: '2.4 MB',
|
||||
uploadTime: 'Today, 10:30 AM'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
fileName: 'presentation.pdf',
|
||||
fileType: 'PDF',
|
||||
thumbnailUrl: null,
|
||||
fileSize: '4.8 MB',
|
||||
uploadTime: 'Yesterday, 3:45 PM'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
fileName: 'promo_video.mp4',
|
||||
fileType: 'Video',
|
||||
thumbnailUrl: null,
|
||||
fileSize: '24.8 MB',
|
||||
uploadTime: 'Aug 28, 2023'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
fileName: 'report_q3.docx',
|
||||
fileType: 'Document',
|
||||
thumbnailUrl: null,
|
||||
fileSize: '1.2 MB',
|
||||
uploadTime: 'Aug 25, 2023'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
fileName: 'podcast_interview.mp3',
|
||||
fileType: 'Audio',
|
||||
thumbnailUrl: null,
|
||||
fileSize: '18.5 MB',
|
||||
uploadTime: 'Aug 20, 2023'
|
||||
}
|
||||
]);
|
||||
|
||||
// Fetch post data by ID
|
||||
const fetchPost = async (id) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// In a real app, you would call an API to get the post
|
||||
// const response = await postApi.getPostById(id);
|
||||
// Object.assign(post, response.data);
|
||||
|
||||
// For demo, we'll use some sample data
|
||||
const samplePosts = [
|
||||
{
|
||||
id: 1,
|
||||
title: '如何高效学习编程',
|
||||
author: '张三',
|
||||
thumbnail: 'https://via.placeholder.com/150',
|
||||
price: 29.99,
|
||||
publishedAt: '2023-06-15 14:30',
|
||||
status: '已发布',
|
||||
mediaTypes: ['文章', '视频'],
|
||||
viewCount: 1254,
|
||||
introduction: '这是一篇关于高效学习编程的文章,包含了多种学习方法和技巧。',
|
||||
selectedMedia: [mediaItems.value[0], mediaItems.value[2]]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '前端开发最佳实践',
|
||||
author: '李四',
|
||||
thumbnail: 'https://via.placeholder.com/150',
|
||||
price: 49.99,
|
||||
publishedAt: '2023-06-10 09:15',
|
||||
status: '草稿',
|
||||
mediaTypes: ['文章'],
|
||||
viewCount: 789,
|
||||
introduction: '探讨现代前端开发的各种最佳实践和设计模式。',
|
||||
selectedMedia: [mediaItems.value[1]]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '数据分析入门指南',
|
||||
author: '王五',
|
||||
thumbnail: 'https://via.placeholder.com/150',
|
||||
price: 0.00,
|
||||
publishedAt: '2023-06-05 16:45',
|
||||
status: '已下架',
|
||||
mediaTypes: ['文章', '音频'],
|
||||
viewCount: 2567,
|
||||
introduction: '介绍数据分析的基础知识和常用工具。',
|
||||
selectedMedia: [mediaItems.value[3], mediaItems.value[4]]
|
||||
}
|
||||
];
|
||||
|
||||
const foundPost = samplePosts.find(p => p.id === parseInt(id));
|
||||
if (foundPost) {
|
||||
Object.assign(post, foundPost);
|
||||
// Initialize selectedMediaItems with the post's media for the dialog
|
||||
selectedMediaItems.value = [...post.selectedMedia];
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '未找到该文章', life: 3000 });
|
||||
router.push('/posts');
|
||||
}
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '加载文章失败', life: 3000 });
|
||||
router.push('/posts');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Open media selection dialog
|
||||
const openMediaDialog = () => {
|
||||
mediaDialogVisible.value = true;
|
||||
loadMediaItems();
|
||||
};
|
||||
|
||||
// Load media items
|
||||
const loadMediaItems = async () => {
|
||||
mediaLoading.value = true;
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await mediaApi.getMediaFiles();
|
||||
// mediaItems.value = response.data;
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
// Using sample data already defined above
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '加载媒体文件失败', life: 3000 });
|
||||
} finally {
|
||||
mediaLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Confirm media selection
|
||||
const confirmMediaSelection = () => {
|
||||
if (selectedMediaItems.value.length) {
|
||||
post.selectedMedia = [...selectedMediaItems.value];
|
||||
errors.selectedMedia = '';
|
||||
}
|
||||
mediaDialogVisible.value = false;
|
||||
};
|
||||
|
||||
// Cancel media selection
|
||||
const cancelMediaSelection = () => {
|
||||
mediaDialogVisible.value = false;
|
||||
};
|
||||
|
||||
// Remove a selected media item
|
||||
const removeMedia = (media) => {
|
||||
const index = post.selectedMedia.findIndex(item => item.id === media.id);
|
||||
if (index > -1) {
|
||||
post.selectedMedia.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Save the post
|
||||
const savePost = async () => {
|
||||
// Reset errors
|
||||
Object.keys(errors).forEach(key => errors[key] = '');
|
||||
|
||||
// Validate form
|
||||
let valid = true;
|
||||
|
||||
if (!post.title.trim()) {
|
||||
errors.title = '请填写文章标题';
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!post.introduction.trim()) {
|
||||
errors.introduction = '请填写文章介绍';
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (post.selectedMedia.length === 0) {
|
||||
errors.selectedMedia = '请选择至少一个媒体文件';
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
toast.add({ severity: 'error', summary: '表单错误', detail: '请检查表单中的错误并修正', life: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// In a real app, you would call an API to update the post
|
||||
// await postApi.updatePost(post.id, post);
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
toast.add({ severity: 'success', summary: '成功', detail: '文章已成功更新', life: 3000 });
|
||||
|
||||
// Navigate back to the post list
|
||||
router.push('/posts');
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '更新文章失败', life: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel and go back to post list
|
||||
const cancelEdit = () => {
|
||||
router.push('/posts');
|
||||
};
|
||||
|
||||
// File type badge severity mapping
|
||||
const getBadgeSeverity = (fileType) => {
|
||||
const map = {
|
||||
'Image': 'info',
|
||||
'PDF': 'danger',
|
||||
'Video': 'warning',
|
||||
'Document': 'primary',
|
||||
'Audio': 'success'
|
||||
};
|
||||
return map[fileType] || 'info';
|
||||
};
|
||||
|
||||
// File type icon mapping
|
||||
const getFileIcon = (file) => {
|
||||
const map = {
|
||||
'Image': 'pi-image',
|
||||
'PDF': 'pi-file-pdf',
|
||||
'Video': 'pi-video',
|
||||
'Document': 'pi-file',
|
||||
'Audio': 'pi-volume-up'
|
||||
};
|
||||
return `pi ${map[file.fileType] || 'pi-file'}`;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Get post ID from route params
|
||||
const postId = route.params.id;
|
||||
if (postId) {
|
||||
fetchPost(postId);
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '未提供文章ID', life: 3000 });
|
||||
router.push('/posts');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<div class="w-full max-w-6xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">编辑文章</h1>
|
||||
<div class="flex gap-2">
|
||||
<Button label="取消" icon="pi pi-times" severity="secondary" @click="cancelEdit" />
|
||||
<Button label="保存" icon="pi pi-check" severity="primary" @click="savePost" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-12">
|
||||
<ProgressSpinner style="width:50px;height:50px" />
|
||||
<span class="mt-4">加载文章数据...</span>
|
||||
</div>
|
||||
|
||||
<Card v-else class="mb-6">
|
||||
<template #content>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Title -->
|
||||
<div class="col-span-2">
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">标题</label>
|
||||
<InputText id="title" v-model="post.title" class="w-full p-inputtext-lg" placeholder="输入文章标题" />
|
||||
<small v-if="errors.title" class="p-error">{{ errors.title }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
<div class="col-span-1">
|
||||
<label for="author" class="block text-sm font-medium text-gray-700 mb-1">作者</label>
|
||||
<InputText id="author" v-model="post.author" class="w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="col-span-1">
|
||||
<label for="price" class="block text-sm font-medium text-gray-700 mb-1">价格 (¥)</label>
|
||||
<InputNumber id="price" v-model="post.price" mode="currency" currency="CNY"
|
||||
:minFractionDigits="2" class="w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="col-span-1">
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">状态</label>
|
||||
<Dropdown id="status" v-model="post.status" :options="statusOptions" class="w-full" />
|
||||
</div>
|
||||
|
||||
<!-- ViewCount (readonly) -->
|
||||
<div class="col-span-1">
|
||||
<label for="viewCount" class="block text-sm font-medium text-gray-700 mb-1">观看次数</label>
|
||||
<InputText id="viewCount" v-model="post.viewCount" class="w-full" readonly />
|
||||
</div>
|
||||
|
||||
<!-- Introduction -->
|
||||
<div class="col-span-2">
|
||||
<label for="introduction" class="block text-sm font-medium text-gray-700 mb-1">文章介绍</label>
|
||||
<Textarea id="introduction" v-model="post.introduction" rows="5" class="w-full"
|
||||
placeholder="输入文章介绍内容" />
|
||||
<small v-if="errors.introduction" class="p-error">{{ errors.introduction }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Media Selection -->
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">媒体资源</label>
|
||||
<div class="p-4 border border-gray-200 rounded-md">
|
||||
<div v-if="post.selectedMedia.length === 0"
|
||||
class="flex justify-center items-center flex-col space-y-3 py-6">
|
||||
<i class="pi pi-image text-gray-400 text-3xl"></i>
|
||||
<p class="text-gray-500">尚未选择任何媒体文件</p>
|
||||
<Button label="选择媒体" icon="pi pi-plus" @click="openMediaDialog" outlined />
|
||||
<small v-if="errors.selectedMedia" class="p-error">{{ errors.selectedMedia }}</small>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="mb-4">
|
||||
<Button label="更改媒体" icon="pi pi-sync" @click="openMediaDialog" outlined />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div v-for="media in post.selectedMedia" :key="media.id"
|
||||
class="relative border border-gray-200 rounded-md p-2 flex items-center">
|
||||
<div v-if="media.thumbnailUrl" class="flex-shrink-0 h-10 w-10 mr-3">
|
||||
<img class="h-10 w-10 object-cover rounded" :src="media.thumbnailUrl"
|
||||
:alt="media.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="getFileIcon(media)" class="text-2xl"></i>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="text-sm font-medium text-gray-900 truncate">{{ media.fileName }}
|
||||
</div>
|
||||
<Badge :value="media.fileType" :severity="getBadgeSeverity(media.fileType)"
|
||||
class="text-xs" />
|
||||
</div>
|
||||
<Button icon="pi pi-times" class="p-button-rounded p-button-text p-button-sm"
|
||||
@click="removeMedia(media)" aria-label="移除" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Media Selection Dialog -->
|
||||
<Dialog v-model:visible="mediaDialogVisible" header="选择媒体" :modal="true" :dismissableMask="true" :closable="true"
|
||||
:style="{ width: '80vw' }" :breakpoints="{ '960px': '90vw' }">
|
||||
<div class="mb-4">
|
||||
<InputText v-model="mediaGlobalFilter" placeholder="搜索媒体..." class="w-full" />
|
||||
</div>
|
||||
|
||||
<DataTable v-model:selection="selectedMediaItems" :value="mediaItems" :loading="mediaLoading" dataKey="id"
|
||||
selectionMode="multiple" :globalFilterFields="['fileName', 'fileType']"
|
||||
:filters="{ global: { value: mediaGlobalFilter, matchMode: 'contains' } }" stripedRows
|
||||
responsiveLayout="scroll">
|
||||
|
||||
<template #empty>
|
||||
<div class="text-center p-4">没有可用的媒体文件</div>
|
||||
</template>
|
||||
|
||||
<template #loading>
|
||||
<div class="flex flex-col items-center justify-center p-4">
|
||||
<ProgressSpinner style="width:50px;height:50px" />
|
||||
<span class="mt-2">加载媒体数据...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column selectionMode="multiple" style="width: 3rem"></Column>
|
||||
|
||||
<Column field="fileName" header="文件名">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center">
|
||||
<div v-if="data.thumbnailUrl" class="flex-shrink-0 h-10 w-10 mr-3">
|
||||
<img class="h-10 w-10 object-cover rounded" :src="data.thumbnailUrl" :alt="data.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="getFileIcon(data)" class="text-2xl"></i>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-900">{{ data.fileName }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="fileType" header="文件类型">
|
||||
<template #body="{ data }">
|
||||
<Badge :value="data.fileType" :severity="getBadgeSeverity(data.fileType)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="fileSize" header="文件大小"></Column>
|
||||
<Column field="uploadTime" header="上传时间"></Column>
|
||||
</DataTable>
|
||||
|
||||
<template #footer>
|
||||
<Button label="取消" icon="pi pi-times" @click="cancelMediaSelection" class="p-button-text" />
|
||||
<Button label="确认选择" icon="pi pi-check" @click="confirmMediaSelection"
|
||||
:disabled="selectedMediaItems.length === 0" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.p-card .p-card-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.p-inputtext-lg {
|
||||
font-size: 1.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.p-inputnumber-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.p-button-sm {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
268
frontend/admin/src/pages/PostPage.vue
Normal file
268
frontend/admin/src/pages/PostPage.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<script setup>
|
||||
import { InputText } from 'primevue';
|
||||
import Badge from 'primevue/badge';
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import ConfirmDialog from 'primevue/confirmdialog';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
import Toast from 'primevue/toast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
// Import useRouter for navigation
|
||||
const router = useRouter();
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
// State for edit dialog (removed "create" functionality since we now have a dedicated page)
|
||||
const postDialog = ref(false);
|
||||
const postDialogTitle = ref('编辑文章');
|
||||
const editMode = ref(true); // Always true now since we only use dialog for editing
|
||||
const currentPost = ref({
|
||||
id: null,
|
||||
title: '',
|
||||
author: '',
|
||||
thumbnail: '',
|
||||
price: 0,
|
||||
publishedAt: '',
|
||||
status: '',
|
||||
mediaTypes: [],
|
||||
viewCount: 0
|
||||
});
|
||||
|
||||
// Post statuses for filtering
|
||||
const statusOptions = ref([
|
||||
{ name: '所有状态', value: null },
|
||||
{ name: '已发布', value: '已发布' },
|
||||
{ name: '草稿', value: '草稿' },
|
||||
{ name: '已下架', value: '已下架' }
|
||||
]);
|
||||
|
||||
// Media types for filtering
|
||||
const mediaTypeOptions = ref([
|
||||
{ name: '所有类型', value: null },
|
||||
{ name: '文章', value: '文章' },
|
||||
{ name: '视频', value: '视频' },
|
||||
{ name: '音频', value: '音频' }
|
||||
]);
|
||||
|
||||
const selectedStatus = ref(statusOptions.value[0]);
|
||||
const globalFilterValue = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
// Sample data - in a real app, this would come from an API
|
||||
const posts = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '如何高效学习编程',
|
||||
author: '张三',
|
||||
thumbnail: 'https://via.placeholder.com/150',
|
||||
price: 29.99,
|
||||
publishedAt: '2023-06-15 14:30',
|
||||
status: '已发布',
|
||||
mediaTypes: ['文章', '视频'],
|
||||
viewCount: 1254
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '前端开发最佳实践',
|
||||
author: '李四',
|
||||
thumbnail: 'https://via.placeholder.com/150',
|
||||
price: 49.99,
|
||||
publishedAt: '2023-06-10 09:15',
|
||||
status: '草稿',
|
||||
mediaTypes: ['文章'],
|
||||
viewCount: 789
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '数据分析入门指南',
|
||||
author: '王五',
|
||||
thumbnail: 'https://via.placeholder.com/150',
|
||||
price: 0.00,
|
||||
publishedAt: '2023-06-05 16:45',
|
||||
status: '已下架',
|
||||
mediaTypes: ['文章', '音频'],
|
||||
viewCount: 2567
|
||||
}
|
||||
]);
|
||||
|
||||
// Navigate to post creation page
|
||||
const navigateToCreatePost = () => {
|
||||
router.push('/posts/create');
|
||||
};
|
||||
|
||||
// Navigate to post edit page
|
||||
const navigateToEditPost = (post) => {
|
||||
router.push(`/posts/edit/${post.id}`);
|
||||
};
|
||||
|
||||
// View post details
|
||||
const viewPost = (post) => {
|
||||
// In a real app, this would navigate to a post detail page
|
||||
toast.add({ severity: 'info', summary: '查看', detail: `查看文章: ${post.title}`, life: 3000 });
|
||||
};
|
||||
|
||||
// Delete post
|
||||
const confirmDelete = (post) => {
|
||||
confirm.require({
|
||||
message: `确定要删除 "${post.title}" 吗?`,
|
||||
header: '确认删除',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: () => {
|
||||
// In a real app, you would call an API to delete the post
|
||||
posts.value = posts.value.filter(p => p.id !== post.id);
|
||||
toast.add({ severity: 'success', summary: '成功', detail: '文章已删除', life: 3000 });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch posts data
|
||||
const fetchPosts = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await postApi.getPosts();
|
||||
// posts.value = response.data;
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
// Using sample data already defined above
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '加载文章失败', life: 3000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPosts();
|
||||
});
|
||||
|
||||
// Status badge severity mapping
|
||||
const getBadgeSeverity = (status) => {
|
||||
const map = {
|
||||
'已发布': 'success',
|
||||
'草稿': 'warning',
|
||||
'已下架': 'danger'
|
||||
};
|
||||
return map[status] || 'info';
|
||||
};
|
||||
|
||||
// Format price to display ¥ symbol
|
||||
const formatPrice = (price) => {
|
||||
return `¥${price.toFixed(2)}`;
|
||||
};
|
||||
|
||||
// Format media types to display as comma-separated string
|
||||
const formatMediaTypes = (mediaTypes) => {
|
||||
return mediaTypes.join(', ');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
|
||||
<div class="w-full">
|
||||
<div class="flex justify-between items-center mb-6 gap-4">
|
||||
<h1 class="text-2xl font-semibold text-gray-800 text-nowrap">文章列表</h1>
|
||||
|
||||
<Button class="text-nowrap !px-8" icon="pi pi-plus" label="创建文章" severity="primary"
|
||||
@click="navigateToCreatePost" />
|
||||
</div>
|
||||
|
||||
<!-- Posts Table -->
|
||||
<div class="card mt-10">
|
||||
<div class="pb-10 flex">
|
||||
<InputText v-model="globalFilterValue" placeholder="搜索文章..." class="flex-1" />
|
||||
</div>
|
||||
|
||||
<DataTable v-model:filters="filters" :value="posts" :paginator="true" :rows="5"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
:rowsPerPageOptions="[5, 10, 25]"
|
||||
currentPageReportTemplate="显示第 {first} 到 {last} 条,共 {totalRecords} 条结果" :loading="loading" dataKey="id"
|
||||
:globalFilterFields="['title', 'author', 'status', 'mediaTypes']"
|
||||
:filters="{ global: { value: globalFilterValue, matchMode: 'contains' } }" stripedRows removableSort
|
||||
class="p-datatable-sm" responsiveLayout="scroll">
|
||||
|
||||
<template #empty>
|
||||
<div class="text-center p-4">未找到文章。</div>
|
||||
</template>
|
||||
|
||||
<template #loading>
|
||||
<div class="flex flex-col items-center justify-center p-4">
|
||||
<ProgressSpinner style="width:50px;height:50px" />
|
||||
<span class="mt-2">加载文章数据...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column field="title" header="标题" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{ data.title }}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="price" header="价格" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm text-gray-900">{{ formatPrice(data.price) }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="publishedAt" header="发布时间" sortable></Column>
|
||||
|
||||
<Column field="status" header="发布状态" sortable>
|
||||
<template #body="{ data }">
|
||||
<Badge :value="data.status" :severity="getBadgeSeverity(data.status)" />
|
||||
</template>
|
||||
<template #filter="{ filterModel, filterCallback }">
|
||||
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="statusOptions"
|
||||
optionLabel="name" optionValue="value" placeholder="所有状态" class="p-column-filter"
|
||||
showClear />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="mediaTypes" header="媒体类型" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm text-gray-900">{{ formatMediaTypes(data.mediaTypes) }}</div>
|
||||
</template>
|
||||
<template #filter="{ filterModel, filterCallback }">
|
||||
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="mediaTypeOptions"
|
||||
optionLabel="name" optionValue="value" placeholder="所有类型" class="p-column-filter"
|
||||
showClear />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="viewCount" header="观看次数" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm text-gray-500">{{ data.viewCount }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="操作" :exportable="false" style="min-width:8rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex justify-center space-x-2">
|
||||
<Button icon="pi pi-pencil" rounded text severity="info" @click="navigateToEditPost(data)"
|
||||
aria-label="编辑" />
|
||||
<Button icon="pi pi-eye" rounded text severity="secondary" @click="viewPost(data)"
|
||||
aria-label="查看" />
|
||||
<Button icon="pi pi-trash" rounded text severity="danger" @click="confirmDelete(data)"
|
||||
aria-label="删除" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Additional styling if needed */
|
||||
</style>
|
||||
@@ -12,6 +12,24 @@ const routes = [
|
||||
name: 'Medias',
|
||||
component: () => import('./pages/MediaPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/posts',
|
||||
name: 'Posts',
|
||||
component: () => import('./pages/PostPage.vue'),
|
||||
},
|
||||
// Add route for post creation
|
||||
{
|
||||
path: '/posts/create',
|
||||
name: 'CreatePost',
|
||||
component: () => import('./pages/PostCreatePage.vue')
|
||||
},
|
||||
// Add route for post editing
|
||||
{
|
||||
path: '/posts/edit/:id',
|
||||
name: 'EditPost',
|
||||
component: () => import('./pages/PostEditPage.vue'),
|
||||
props: true
|
||||
},
|
||||
];
|
||||
|
||||
// Create the router instance
|
||||
|
||||
Reference in New Issue
Block a user