Files
quyun/frontend/admin/src/pages/PostCreatePage.vue
2025-04-22 20:01:50 +08:00

462 lines
18 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 } from "@/utils/date";
import { formatFileSize } from "@/utils/filesize";
import { getFileIcon, getFileTypeByMimeCN } from "@/utils/filetype";
import { useToast } from 'primevue/usetoast';
import { computed, reactive, ref } from 'vue';
import { 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 toast = useToast();
// Form state
const post = reactive({
title: '',
price: 0,
discount: 100, // Add discount field with default value
introduction: '',
selectedMedia: [],
medias: [],
status: 0,
head_images: [], // Add head images array
});
// Validation state
const errors = reactive({
title: '',
introduction: '',
selectedMedia: '',
discount: '',
head_images: '',
});
const headImageUrls = ref([]); // Store preview URLs
// Media selection dialog state
const mediaDialogVisible = ref(false);
const selectedMediaItems = ref([]);
const mediaLoading = ref(false);
const mediaGlobalFilter = ref('');
const mediaItems = 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);
});
// Status options
const statusOptions = [
{ label: '发布', value: 1 },
{ label: '草稿', value: 0 }
];
// Add media selection target
const mediaSelectionTarget = ref('content'); // 'content' or 'headImages'
// 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 with pagination
const loadMediaItems = async () => {
mediaLoading.value = true;
try {
const response = await mediaService.getMedias({
page: mediaCurrentPage.value,
limit: mediaRows.value
});
console.log(response.data.items)
mediaItems.value = response.data.items;
mediaTotalRecords.value = response.data.total;
} catch (error) {
toast.add({ severity: 'error', summary: '错误', detail: '加载媒体文件失败', life: 3000 });
} finally {
mediaLoading.value = false;
}
};
// Handle media pagination
const onMediaPage = (event) => {
mediaFirst.value = event.first;
mediaRows.value = event.rows;
mediaCurrentPage.value = Math.floor(event.first / event.rows) + 1;
loadMediaItems();
};
// 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 head image
const removeHeadImage = (mediaId) => {
const index = post.head_images.indexOf(mediaId);
if (index > -1) {
post.head_images.splice(index, 1);
loadHeadImagePreviews();
}
};
// Save the post
const savePost = async () => {
// Reset errors
Object.keys(errors).forEach(key => errors[key] = '');
console.log(post.value)
// 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;
}
// check discount
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);
const resp = await postService.createPost(post);
console.log(resp)
if (resp.status !== 200) {
toast.add({ severity: 'error', summary: '错误', detail: resp.message, life: 3000 });
return;
}
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');
};
// Add head image preview loading
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);
}
}
};
</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>
<div>
<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="输入折扣百分比" />
</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="text-red-500">{{ 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-5xl!"></i>
<p class="text-gray-500">尚未选择任何媒体文件</p>
<Button label="选择媒体" icon="pi pi-plus" @click="openMediaDialog" outlined />
<small v-if="errors.selectedMedia" class="text-red-500">{{ 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-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>
<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.file_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>