462 lines
18 KiB
Vue
462 lines
18 KiB
Vue
<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> |