Files
quyun/frontend/admin/src/pages/PostEditPage.vue
2025-05-08 14:35:20 +08:00

518 lines
20 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 { formatDate, formatDuration } from "@/utils/date";
import { formatFileSize } from "@/utils/filesize";
import { getFileIcon, getFileTypeByMimeCN } from "@/utils/filetype";
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';
import {
BsFileEarmark,
BsFileExcel,
BsFileImage,
BsFileMusic,
BsFilePdf,
BsFilePlayFill,
BsFilePpt,
BsFileText,
BsFileWord,
BsFileZip
} from 'vue-icons-plus/bs';
// Create icons object
const icons = {
BsFileEarmark,
BsFileExcel,
BsFileImage,
BsFileMusic,
BsFilePdf,
BsFilePlayFill,
BsFilePpt,
BsFileText,
BsFileWord,
BsFileZip
};
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,
head_images: [], // Add head images array
});
// Status options
const statusOptions = [
{ label: '发布', value: 1 },
{ label: '草稿', value: 0 }
];
// Validation state
const errors = reactive({
title: '',
introduction: '',
selectedMedia: '',
discount: '',
head_images: '', // Add head images validation
});
const headImageUrls = ref([]); // Store preview URLs
// 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([]);
// Add media selection target
const mediaSelectionTarget = ref('content'); // 'content' or 'headImages'
// 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) || [];
post.head_images = postData.head_images || []; // Add head images
loadHeadImagePreviews(); // Load head image previews
} catch (error) {
toast.add({ severity: 'error', summary: '错误', detail: '加载曲谱失败', life: 3000 });
router.push('/posts');
} finally {
loading.value = false;
}
};
// Open media selection dialog
const openMediaDialog = async (target = 'content') => {
mediaSelectionTarget.value = target;
mediaDialogVisible.value = true;
mediaCurrentPage.value = 1;
mediaFirst.value = 0;
await loadMediaItems();
// Set selected items based on target and match with loaded media items
const selectedIds = target === 'headImages' ? post.head_images : post.selectedMedia.map(m => m.id);
selectedMediaItems.value = mediaItems.value.filter(item => selectedIds.includes(item.id));
};
// 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) {
if (mediaSelectionTarget.value === 'headImages') {
if (selectedMediaItems.value.length > 3) {
toast.add({ severity: 'warn', summary: '提示', detail: '展示图片最多选择3张', life: 3000 });
return;
}
// Store only IDs
post.head_images = selectedMediaItems.value.map(item => item.id);
errors.head_images = '';
loadHeadImagePreviews();
} else {
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);
}
};
// Remove a selected head image
const removeHeadImage = (mediaId) => {
const index = post.head_images.indexOf(mediaId);
if (index > -1) {
post.head_images.splice(index, 1);
loadHeadImagePreviews();
}
};
// Load head image previews
const loadHeadImagePreviews = async () => {
headImageUrls.value = [];
for (const mediaId of post.head_images) {
try {
const url = await mediaService.getMediaPreviewUrl(mediaId);
headImageUrls.value.push({ id: mediaId, url });
} catch (error) {
console.error('Failed to load preview for media:', mediaId);
}
}
};
// 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 (post.head_images.length === 0) {
errors.head_images = '请选择至少一张展示图片';
valid = false;
}
if (!valid) {
toast.add({ severity: 'error', summary: '表单错误', detail: '请检查表单中的错误并修正', life: 3000 });
return;
}
try {
post.medias = post.selectedMedia.map(media => media.id);
// Remove head_image_ids, use head_images directly since it now contains IDs
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');
};
// Add pagination handler
const onMediaPage = (event) => {
mediaFirst.value = event.first;
mediaRows.value = event.rows;
mediaCurrentPage.value = Math.floor(event.first / event.rows) + 1;
loadMediaItems();
};
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">
<!-- Add Head Images Selection before Title -->
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">
展示图片 <span class="text-gray-500 text-xs">(最多3张)</span>
</label>
<div class="p-4 border border-gray-200 rounded-md">
<div v-if="post.head_images.length === 0"
class="flex justify-center items-center flex-col space-y-3 py-6">
<i class="pi pi-image text-gray-400 text-5xl!"></i>
<p class="text-gray-500">请选择展示图片</p>
<Button label="选择图片" icon="pi pi-plus" @click="openMediaDialog('headImages')" outlined />
<small v-if="errors.head_images" class="text-red-500">{{ errors.head_images }}</small>
</div>
<div v-else>
<div class="mb-4 flex justify-between items-center">
<Button label="更换图片" icon="pi pi-plus" @click="openMediaDialog('headImages')"
:disabled="post.head_images.length >= 3" outlined />
<span class="text-sm text-gray-500">
{{ post.head_images.length }}/3
</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div v-for="(mediaId, index) in post.head_images" :key="mediaId"
class="relative aspect-video">
<img v-if="headImageUrls[index]" :src="headImageUrls[index].url"
class="w-full h-full object-cover rounded bg-gray-200" :alt="mediaId">
<Button icon="pi pi-times"
class="absolute! top-2 right-2 p-button-rounded p-button-danger p-button-sm"
@click="removeHeadImage(mediaId)" />
</div>
</div>
</div>
</div>
</div>
<!-- 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" :minFractionDigits="0" :maxFractionDigits="0"
class="w-full" placeholder="输入价格,单位:分" />
</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-1 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
class="flex-shrink-0 h-10 w-10 mr-3 bg-gray-100 rounded flex items-center justify-center">
<component :is="getFileIcon(media, icons)" class="text-xl text-gray-600" />
</div>
<div class="flex-1 overflow-hidden">
<div class="text-sm font-medium text-gray-900 truncate">{{ media.name }}
</div>
<!-- <Badge :value="getFileTypeByMimeCN(media.mime_type)" /> -->
<div class="flex gap-2">
<Badge :value="getFileTypeByMimeCN(media.mime_type)" />
<Badge v-if="media.metas?.short" severity="warn" value="试听" />
<Badge v-if="media.metas?.duration" severity="secondary"
:value="'时长:' + formatDuration(media.metas?.duration)" />
</div>
</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="getFileTypeByMimeCN(data.mime_type)" />
</template>
</Column>
<Column field="fileSize" header="文件大小">
<template #body="{ data }">
{{ formatFileSize(data.size) }}
</template>
</Column>
<Column field="createdAt" header="上传时间">
<template #body="{ data }">
{{ formatDate(data.upload_time) }}
</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>