Files
quyun/frontend/admin/src/pages/PostEditPage.vue
2025-04-09 21:16:55 +08:00

472 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { mediaService } from '@/api/mediaService';
import { postService } from '@/api/postService';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
// PrimeVue components
import Badge from 'primevue/badge';
import Button from 'primevue/button';
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 RadioButton from 'primevue/radiobutton';
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,
discount: 100,
introduction: '',
selectedMedia: [],
medias: [],
status: 0,
});
// Status options
const statusOptions = [
{ label: '发布', value: 1 },
{ label: '草稿', value: 0 }
];
// Validation state
const errors = reactive({
title: '',
introduction: '',
selectedMedia: '',
discount: ''
});
// Media selection dialog state
const mediaDialogVisible = ref(false);
const selectedMediaItems = ref([]);
const mediaLoading = ref(false);
const mediaGlobalFilter = ref('');
// Add pagination state for media dialog
const mediaFirst = ref(0);
const mediaRows = ref(10);
const mediaTotalRecords = ref(0);
const mediaCurrentPage = ref(1);
const mediaTotalPages = computed(() => {
return Math.ceil(mediaTotalRecords.value / mediaRows.value);
});
// 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 {
const response = await postService.getPost(id);
if (response.status !== 200) {
toast.add({ severity: 'error', summary: '错误', detail: response.message, life: 3000 });
router.push('/posts');
return;
}
const postData = response.data;
post.id = postData.id;
post.title = postData.title;
post.price = postData.price;
post.discount = postData.discount || 100;
post.introduction = postData.description;
post.status = postData.status;
post.selectedMedia = postData.medias || [];
post.medias = postData.medias?.map(media => media.id) || [];
} 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;
mediaCurrentPage.value = 1;
mediaFirst.value = 0;
loadMediaItems();
};
// Load media items
const loadMediaItems = async () => {
mediaLoading.value = true;
try {
const response = await mediaService.getMedias({
page: mediaCurrentPage.value,
limit: mediaRows.value
});
mediaItems.value = response.data.items;
mediaTotalRecords.value = response.data.total;
} 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 (post.discount < 0 || post.discount > 100) {
errors.discount = '折扣必须在0到100之间';
valid = false;
}
if (!valid) {
toast.add({ severity: 'error', summary: '表单错误', detail: '请检查表单中的错误并修正', life: 3000 });
return;
}
try {
post.medias = post.selectedMedia.map(media => media.id);
const resp = await postService.updatePost(post.id, post);
if (resp.status !== 200) {
toast.add({ severity: 'error', summary: '错误', detail: resp.message, life: 3000 });
return;
}
toast.add({ severity: 'success', summary: '成功', detail: '文章已成功更新', life: 3000 });
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'}`;
};
// Add pagination handler
const onMediaPage = (event) => {
mediaFirst.value = event.first;
mediaRows.value = event.rows;
mediaCurrentPage.value = Math.floor(event.first / event.rows) + 1;
loadMediaItems();
};
// Add format file size helper
const formatFileSize = (bytes) => {
if (!bytes && bytes !== 0) return '0 B';
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const base = 1024;
let size = Number(bytes);
if (isNaN(size)) return '0 B';
const i = Math.floor(Math.log(size) / Math.log(base));
size = size / Math.pow(base, i);
return `${size.toFixed(2)} ${sizes[i]}`;
};
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>
<div v-else>
<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="text-red-500">{{ 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>
<!-- Discount -->
<div class="col-span-1">
<label for="discount" class="block text-sm font-medium text-gray-700 mb-1">折扣 (%)</label>
<InputNumber id="discount" v-model="post.discount" :min="0" :max="100" :minFractionDigits="0"
:maxFractionDigits="0" class="w-full" placeholder="输入折扣百分比" />
<small v-if="errors.discount" class="text-red-500">{{ errors.discount }}</small>
</div>
<!-- Status -->
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">状态</label>
<div class="flex gap-4">
<div v-for="option in statusOptions" :key="option.value" class="flex items-center">
<RadioButton :value="option.value" v-model="post.status"
:inputId="'status_' + option.value" />
<label :for="'status_' + option.value" class="ml-2">{{ option.label }}</label>
</div>
</div>
</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.name">
</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.name }}
</div>
<Badge :value="media.file_type" :severity="getBadgeSeverity(media.file_type)"
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>
</div>
</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"
:paginator="true" v-model:first="mediaFirst" v-model:rows="mediaRows" :totalRecords="mediaTotalRecords"
@page="onMediaPage" selectionMode="multiple"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
:rows-per-page-options="[10, 25, 50]" currentPageReportTemplate="第 {first} 到 {last} 条,共 {totalRecords} 条"
:lazy="true" :showCurrentPageReport="true">
<template #paginatorLeft>
<div class="flex items-center">
每页: {{ mediaRows }}
</div>
</template>
<template #paginatorRight>
<div class="flex items-center">
{{ mediaCurrentPage }} {{ mediaTotalPages }}
</div>
</template>
<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="text-sm font-medium text-gray-900">{{ data.name }}</div>
</template>
</Column>
<Column field="fileType" header="文件类型">
<template #body="{ data }">
<Badge :value="data.file_type" :severity="getBadgeSeverity(data.file_type)" />
</template>
</Column>
<Column field="fileSize" header="文件大小">
<template #body="{ data }">
{{ formatFileSize(data.file_size) }}
</template>
</Column>
<Column field="createdAt" header="上传时间">
<template #body="{ data }">
{{ new Date(data.upload_time).toLocaleString('zh-CN') }}
</template>
</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>