feat(portal): completely refactor content publish page to structured form layout with multi-media support
This commit is contained in:
297
frontend/portal/src/views/creator/ContentsEditView.vue
Normal file
297
frontend/portal/src/views/creator/ContentsEditView.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-white rounded-xl overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="px-8 py-4 border-b border-slate-100 flex items-center justify-between bg-white z-10 shadow-sm sticky top-0">
|
||||
<div class="flex items-center gap-4 w-1/3">
|
||||
<button @click="$router.back()"
|
||||
class="text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"><i
|
||||
class="pi pi-arrow-left text-xl"></i></button>
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-1 bg-green-50 text-green-700 text-xs font-medium rounded-full animate-pulse">
|
||||
<i class="pi pi-cloud-upload"></i> {{ autoSaveStatus }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex justify-center">
|
||||
<div class="text-lg font-bold text-slate-900 truncate max-w-md" :class="!fullTitle ? 'text-slate-400' : ''">
|
||||
{{ fullTitle || '新内容标题预览' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 w-1/3 justify-end">
|
||||
<button
|
||||
class="px-6 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 font-bold transition-colors cursor-pointer">存草稿</button>
|
||||
<button @click="submit"
|
||||
class="px-8 py-2 bg-primary-600 text-white rounded-lg font-bold hover:bg-primary-700 shadow-lg shadow-primary-200 transition-all active:scale-95 cursor-pointer">发布</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Form -->
|
||||
<div class="flex-1 overflow-y-auto bg-slate-50/50 p-8 md:p-12">
|
||||
<div class="max-w-screen-xl mx-auto bg-white p-10 rounded-2xl border border-slate-200 shadow-sm space-y-10">
|
||||
|
||||
<!-- Row 1: Genre & Key -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-2">曲种 <span
|
||||
class="text-red-500">*</span></label>
|
||||
<Select v-model="form.genre" :options="genres" placeholder="选择曲种" class="w-full h-12" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-2">主定调</label>
|
||||
<Select v-model="form.key" :options="keys" placeholder="选择定调" class="w-full h-12" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Titles -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-2">剧目名 <span
|
||||
class="text-red-500">*</span></label>
|
||||
<input v-model="form.playName" type="text" placeholder="如《锁麟囊》"
|
||||
class="w-full h-12 px-4 border border-slate-200 rounded-lg focus:border-primary-500 outline-none transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-2">选段名</label>
|
||||
<input v-model="form.selectionName" type="text" placeholder="如“春秋亭”"
|
||||
class="w-full h-12 px-4 border border-slate-200 rounded-lg focus:border-primary-500 outline-none transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-2">附加信息</label>
|
||||
<input v-model="form.extraInfo" type="text" placeholder="如“程砚秋”"
|
||||
class="w-full h-12 px-4 border border-slate-200 rounded-lg focus:border-primary-500 outline-none transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Price -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-3">价格策略</label>
|
||||
<div class="p-6 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<div class="flex gap-4 mb-6">
|
||||
<button @click="form.priceType = 'free'"
|
||||
class="flex-1 py-3 font-bold rounded-lg border-2 transition-all cursor-pointer"
|
||||
:class="form.priceType === 'free' ? 'border-primary-600 bg-white text-primary-600' : 'border-transparent bg-white text-slate-500 hover:bg-slate-100'">免费</button>
|
||||
<button @click="form.priceType = 'paid'"
|
||||
class="flex-1 py-3 font-bold rounded-lg border-2 transition-all cursor-pointer"
|
||||
:class="form.priceType === 'paid' ? 'border-primary-600 bg-white text-primary-600' : 'border-transparent bg-white text-slate-500 hover:bg-slate-100'">付费</button>
|
||||
<button @click="form.priceType = 'member'"
|
||||
class="flex-1 py-3 font-bold rounded-lg border-2 transition-all cursor-pointer"
|
||||
:class="form.priceType === 'member' ? 'border-amber-500 bg-white text-amber-600' : 'border-transparent bg-white text-slate-500 hover:bg-slate-100'">会员专享</button>
|
||||
</div>
|
||||
|
||||
<div v-if="form.priceType === 'paid'"
|
||||
class="flex flex-wrap items-center gap-8 animate-in fade-in slide-in-from-top-2">
|
||||
<div class="relative w-48">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 font-bold">¥</span>
|
||||
<input v-model="form.price" type="number"
|
||||
class="w-full h-12 pl-8 pr-4 border border-slate-200 rounded-lg focus:border-primary-500 font-bold text-lg outline-none"
|
||||
placeholder="0.00">
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" v-model="form.enableTrial" id="trial"
|
||||
class="w-5 h-5 rounded border-slate-300 text-primary-600 focus:ring-primary-500 cursor-pointer">
|
||||
<label for="trial" class="text-sm font-bold text-slate-700 cursor-pointer">开启试看</label>
|
||||
</div>
|
||||
<div v-if="form.enableTrial" class="flex items-center gap-2">
|
||||
<input v-model="form.trialTime" type="number"
|
||||
class="w-20 h-10 px-2 border border-slate-200 rounded-lg text-center outline-none">
|
||||
<span class="text-sm text-slate-500">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 4: Abstract -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-2">摘要</label>
|
||||
<textarea v-model="form.abstract" rows="4"
|
||||
class="w-full p-4 rounded-xl border border-slate-200 focus:border-primary-500 outline-none resize-none transition-colors"
|
||||
placeholder="请输入内容简介..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Row 5: Covers (Max 3) -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-3">封面 (最多3张)</label>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div v-for="(img, idx) in form.covers" :key="idx"
|
||||
class="relative group aspect-video rounded-lg overflow-hidden bg-slate-100 border border-slate-200">
|
||||
<img :src="img" class="w-full h-full object-cover">
|
||||
<button @click="removeCover(idx)"
|
||||
class="absolute top-1 right-1 w-6 h-6 bg-black/50 hover:bg-red-500 text-white rounded-full flex items-center justify-center transition-colors cursor-pointer"><i
|
||||
class="pi pi-times text-xs"></i></button>
|
||||
</div>
|
||||
<div v-if="form.covers.length < 3" @click="triggerUpload('cover')"
|
||||
class="aspect-video rounded-lg border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 hover:border-primary-500 hover:bg-primary-50 hover:text-primary-600 cursor-pointer transition-all">
|
||||
<i class="pi pi-plus text-2xl mb-1"></i>
|
||||
<span class="text-xs font-bold">上传封面</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 6: Video -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-3">视频列表</label>
|
||||
<div class="space-y-3">
|
||||
<div v-for="(file, idx) in form.videos" :key="idx"
|
||||
class="flex items-center gap-4 p-3 border border-slate-200 rounded-lg bg-slate-50">
|
||||
<div class="w-10 h-10 bg-blue-100 text-blue-600 rounded flex items-center justify-center"><i
|
||||
class="pi pi-video"></i></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-sm text-slate-900 truncate">{{ file.name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ file.size }}</div>
|
||||
</div>
|
||||
<button @click="removeMedia('videos', idx)"
|
||||
class="text-slate-400 hover:text-red-500 px-2 cursor-pointer"><i
|
||||
class="pi pi-trash"></i></button>
|
||||
</div>
|
||||
<div @click="triggerUpload('video')"
|
||||
class="h-14 border-2 border-dashed border-slate-300 rounded-lg flex items-center justify-center text-slate-500 hover:border-blue-500 hover:bg-blue-50 hover:text-blue-600 cursor-pointer transition-all font-bold gap-2">
|
||||
<i class="pi pi-video"></i> 添加视频
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 7: Audio -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-3">音频列表</label>
|
||||
<div class="space-y-3">
|
||||
<div v-for="(file, idx) in form.audios" :key="idx"
|
||||
class="flex items-center gap-4 p-3 border border-slate-200 rounded-lg bg-slate-50">
|
||||
<div class="w-10 h-10 bg-purple-100 text-purple-600 rounded flex items-center justify-center"><i
|
||||
class="pi pi-microphone"></i></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-sm text-slate-900 truncate">{{ file.name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ file.size }}</div>
|
||||
</div>
|
||||
<button @click="removeMedia('audios', idx)"
|
||||
class="text-slate-400 hover:text-red-500 px-2 cursor-pointer"><i
|
||||
class="pi pi-trash"></i></button>
|
||||
</div>
|
||||
<div @click="triggerUpload('audio')"
|
||||
class="h-14 border-2 border-dashed border-slate-300 rounded-lg flex items-center justify-center text-slate-500 hover:border-purple-500 hover:bg-purple-50 hover:text-purple-600 cursor-pointer transition-all font-bold gap-2">
|
||||
<i class="pi pi-microphone"></i> 添加音频
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 8: Images -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 mb-3">图片列表</label>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div v-for="(file, idx) in form.images" :key="idx"
|
||||
class="relative group aspect-square rounded-lg overflow-hidden border border-slate-200">
|
||||
<img :src="file.url" class="w-full h-full object-cover">
|
||||
<div
|
||||
class="absolute bottom-0 left-0 w-full bg-black/60 text-white text-xs p-1 truncate text-center">
|
||||
{{ file.name }}</div>
|
||||
<button @click="removeMedia('images', idx)"
|
||||
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"><i
|
||||
class="pi pi-times text-xs"></i></button>
|
||||
</div>
|
||||
<div @click="triggerUpload('image')"
|
||||
class="aspect-square border-2 border-dashed border-slate-300 rounded-lg flex flex-col items-center justify-center text-slate-500 hover:border-orange-500 hover:bg-orange-50 hover:text-orange-600 cursor-pointer transition-all">
|
||||
<i class="pi pi-image text-2xl mb-1"></i>
|
||||
<span class="text-xs font-bold">添加图片</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="file" ref="fileInput" class="hidden" @change="handleFileChange" multiple>
|
||||
<Toast />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Select from 'primevue/select';
|
||||
import Toast from 'primevue/toast';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const fileInput = ref(null);
|
||||
const currentUploadType = ref('');
|
||||
|
||||
const autoSaveStatus = ref('已自动保存');
|
||||
|
||||
const form = reactive({
|
||||
genre: null,
|
||||
playName: '',
|
||||
selectionName: '',
|
||||
extraInfo: '',
|
||||
abstract: '',
|
||||
priceType: 'free',
|
||||
price: 9.9,
|
||||
enableTrial: true,
|
||||
trialTime: 60,
|
||||
key: null,
|
||||
covers: [],
|
||||
videos: [],
|
||||
audios: [],
|
||||
images: []
|
||||
});
|
||||
|
||||
const genres = ['京剧', '昆曲', '越剧', '黄梅戏', '豫剧', '评剧'];
|
||||
const keys = ['C大调', 'D大调', 'E大调', 'F大调', 'G大调', 'A大调', 'B大调', '降E大调'];
|
||||
|
||||
const fullTitle = computed(() => {
|
||||
let title = '';
|
||||
if (form.playName) title += `《${form.playName}》`;
|
||||
if (form.selectionName) title += ` ${form.selectionName}`;
|
||||
if (form.extraInfo) title += ` (${form.extraInfo})`;
|
||||
return title;
|
||||
});
|
||||
|
||||
const triggerUpload = (type) => {
|
||||
currentUploadType.value = type;
|
||||
fileInput.value.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event) => {
|
||||
const files = event.target.files;
|
||||
if (!files.length) return;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const mockUrl = URL.createObjectURL(file); // For preview
|
||||
|
||||
if (currentUploadType.value === 'cover') {
|
||||
if (form.covers.length < 3) form.covers.push(mockUrl);
|
||||
} else if (currentUploadType.value === 'video') {
|
||||
form.videos.push({ name: file.name, size: '25MB', url: mockUrl });
|
||||
} else if (currentUploadType.value === 'audio') {
|
||||
form.audios.push({ name: file.name, size: '5MB', url: mockUrl });
|
||||
} else if (currentUploadType.value === 'image') {
|
||||
form.images.push({ name: file.name, size: '1MB', url: mockUrl });
|
||||
}
|
||||
}
|
||||
|
||||
// Reset input to allow re-uploading same file
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const removeCover = (idx) => form.covers.splice(idx, 1);
|
||||
const removeMedia = (type, idx) => form[type].splice(idx, 1);
|
||||
|
||||
const submit = () => {
|
||||
toast.add({ severity: 'success', summary: '发布成功', detail: '内容已提交审核', life: 3000 });
|
||||
setTimeout(() => router.push('/creator/contents'), 1500);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference "../../assets/main.css";
|
||||
|
||||
:deep(.p-select) {
|
||||
@apply border-slate-200 rounded-lg shadow-none;
|
||||
}
|
||||
|
||||
:deep(.p-select:not(.p-disabled).p-focus) {
|
||||
@apply border-primary-500 ring-2 ring-primary-100 shadow-none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user