| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705 |
- <template>
- <div class="train-form bg-white p-6 rounded h-full overflow-y-auto">
- <div class="form-breadcrumb mb-4 text-gray-400 text-xs">
- 课程管理 / 培训课程 / {{ initialData ? '编辑' : '新增' }}{{ type === 'video' ? '视频' : type === 'live' ? '直播' : '线下' }}培训
- </div>
- <div class="form-header mb-8">
- <div class="text-xl font-bold">{{ initialData ? '编辑' : '新增' }}{{ type === 'video' ? '视频培训' : type === 'live' ? '直播培训' : '线下培训' }}</div>
- </div>
- <div class="form-wrapper border-2 border-blue-500 rounded-lg p-10 bg-white relative">
- <div class="section-title font-bold mb-8 text-base">基础信息</div>
- <el-form
- ref="formRef"
- :model="form"
- :rules="rules"
- label-width="100px"
- label-position="right"
- >
- <el-form-item label="培训名称" prop="name" required>
- <el-input v-model="form.name" placeholder="请输入培训名称" style="width: 400px" />
- </el-form-item>
- <template v-if="type === 'video'">
- <el-form-item label="培训封面" prop="thumbnail" required>
- <div class="thumbnail-uploader">
- <input
- ref="thumbnailInputRef"
- type="file"
- accept=".jpg,.jpeg,.png"
- class="hidden"
- @change="handleThumbnailFileChange"
- />
- <el-upload
- ref="thumbnailUploadRef"
- class="avatar-uploader"
- action="#"
- :auto-upload="false"
- :show-file-list="false"
- accept=".jpg,.jpeg,.png"
- @click="handleUploadThumbnail"
- >
- <img v-if="form.thumbnailUrl" :src="form.thumbnailUrl" class="avatar-preview" />
- <el-icon v-else class="avatar-uploader-icon"><i-ep-plus /></el-icon>
- </el-upload>
- <div class="upload-hint text-gray-400 text-xs mt-2">
- 建议尺寸640*320,小于10M的JPG、PNG格式图片
- </div>
- </div>
- </el-form-item>
- <el-form-item label="课程描述" prop="description">
- <el-input
- v-model="form.description"
- type="textarea"
- :rows="4"
- placeholder="从零开始学西班牙语,直达欧标B2水平!"
- style="width: 500px"
- />
- </el-form-item>
- <el-form-item label="课程类型" prop="videoList">
- <input
- ref="videoInputRef"
- type="file"
- accept=".mp4,.avi,.wmv,.mov,.flv,.rmvb,.3gp,.m4v,.mkv"
- class="hidden"
- @change="handleVideoFileChange"
- />
- <el-button type="primary" size="small" :loading="videoUploading" @click="handleUploadVideo">
- <el-icon class="mr-1"><i-ep-plus /></el-icon>
- 上传视频
- </el-button>
- <div class="upload-hint text-gray-400 text-xs mt-1 mb-2">
- 支持mp4、avi、wmv、mov、flv、rmvb、3gp、m4v、mkv格式;单个文件最大不超过10G
- </div>
- <el-table :data="form.videoList" size="small" class="video-table mt-2 border">
- <el-table-column label="视频信息" prop="name" min-width="150">
- <template #default="scope">
- <div class="flex items-center">
- <el-icon class="mr-2 text-blue-500"><i-ep-video-play /></el-icon>
- <span class="text-blue-500 underline cursor-pointer">{{ scope.row.name }}</span>
- </div>
- </template>
- </el-table-column>
- <el-table-column label="视频大小" prop="fileSize" width="100" />
- <el-table-column label="视频类型" prop="fileType" width="80" />
- <el-table-column label="视频时长" prop="duration" width="100" />
- <el-table-column label="操作" width="80" align="center">
- <template #default="scope">
- <el-link type="primary" :underline="false" @click="removeVideo(scope.$index)">删除</el-link>
- </template>
- </el-table-column>
- </el-table>
- </el-form-item>
- </template>
- <template v-else-if="type === 'offline'">
- <el-form-item label="培训描述" prop="description">
- <el-input
- v-model="form.description"
- type="textarea"
- :rows="4"
- placeholder="从零开始学西班牙语,直达欧标B2水平!"
- style="width: 500px"
- />
- </el-form-item>
- </template>
- <div class="flex flex-wrap items-center">
- <el-form-item label="岗位级别" prop="jobLevel" required>
- <el-select v-model="form.jobLevel" placeholder="请选择" style="width: 180px">
- <el-option
- v-for="item in jobLevelOptions"
- :key="item.value"
- :label="item.label"
- :value="item.value"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="岗位" prop="job" class="ml-4">
- <el-select v-model="form.job" placeholder="请选择" style="width: 180px" filterable>
- <el-option
- v-for="item in jobOptions"
- :key="item.value"
- :label="item.label"
- :value="item.value"
- />
- </el-select>
- </el-form-item>
- </div>
- <el-form-item label="岗位类型" prop="jobType" required>
- <el-select v-model="form.jobType" placeholder="全职" style="width: 180px">
- <el-option
- v-for="item in jobTypeOptions"
- :key="item.value"
- :label="item.label"
- :value="item.value"
- />
- </el-select>
- </el-form-item>
- <template v-if="type === 'video'">
- <el-form-item label="排序号" prop="sortOrder">
- <el-input v-model="form.sortOrder" placeholder="请输入" style="width: 320px" />
- </el-form-item>
- </template>
- <template v-if="type === 'offline'">
- <el-form-item label="培训地址" prop="addressDetail" required>
- <div class="address-container">
- <el-cascader
- v-model="form.region"
- :options="regionOptions"
- :props="regionProps"
- clearable
- placeholder="省 / 市 / 区"
- class="region-cascader"
- />
- <el-input v-model="form.addressDetail" placeholder="请输入具体地址" class="address-detail-input" />
- </div>
- </el-form-item>
- <el-form-item label="培训时间" prop="timeRange" required>
- <el-date-picker
- v-model="form.timeRange"
- type="datetimerange"
- range-separator="-"
- start-placeholder="YYYY-MM-DD HH:mm:ss"
- end-placeholder="YYYY-MM-DD HH:mm:ss"
- value-format="YYYY-MM-DD HH:mm:ss"
- style="width: 400px"
- />
- </el-form-item>
- <el-form-item label="报名时间" prop="applyRange" required>
- <el-date-picker
- v-model="form.applyRange"
- type="datetimerange"
- range-separator="-"
- start-placeholder="YYYY-MM-DD HH:mm:ss"
- end-placeholder="YYYY-MM-DD HH:mm:ss"
- value-format="YYYY-MM-DD HH:mm:ss"
- style="width: 400px"
- />
- </el-form-item>
- <el-form-item label="主办单位" prop="organizer">
- <el-input v-model="form.organizer" placeholder="主办单位AAAAA" style="width: 320px" />
- </el-form-item>
- <el-form-item label="标签" prop="tags">
- <div class="flex items-center gap-2">
- <el-tag
- v-for="tag in tagList"
- :key="tag"
- closable
- @close="handleCloseTag(tag)"
- >
- {{ tag }}
- </el-tag>
- <el-input
- v-if="inputTagVisible"
- ref="inputTagRef"
- v-model="inputTagValue"
- size="small"
- style="width: 80px"
- @keyup.enter="handleInputTagConfirm"
- @blur="handleInputTagConfirm"
- />
- <el-button v-else size="small" @click="showInputTag">
- <el-icon><i-ep-plus /></el-icon>
- 标签
- </el-button>
- </div>
- </el-form-item>
- </template>
- <div class="flex gap-6 mt-12">
- <el-button type="primary" class="submit-btn" @click="handleSubmit">提交</el-button>
- <el-button class="back-btn" @click="$emit('cancel')">返回</el-button>
- </div>
- </el-form>
- </div>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, reactive, computed, onMounted, nextTick } from 'vue';
- import { ElMessage } from 'element-plus';
- import { regionData } from 'element-china-area-data';
- import type { FormInstance, FormRules, UploadInstance } from 'element-plus';
- import { uploadTrainingFile } from '../../../api/main/training/index';
- import { listPosition } from '../../../api/main/position/index';
- import { getDicts } from '../../../api/system/dict/data/index';
- const props = defineProps<{
- initialData?: any;
- type: string;
- }>();
- const emit = defineEmits(['cancel', 'submit']);
- const formRef = ref<FormInstance>();
- const form = reactive({
- id: undefined as any,
- name: '',
- thumbnail: undefined as any,
- thumbnailUrl: '',
- description: '',
- videoList: [] as any[],
- jobLevel: '',
- job: '',
- jobType: '',
- sortOrder: '',
- city: '上海市',
- area: '黄浦区',
- region: ['上海市', '上海市', '黄浦区'] as string[],
- addressDetail: '',
- timeRange: [] as string[],
- applyRange: [] as string[],
- organizer: '',
- tags: '',
- status: 1
- });
- /** 将 element-china-area-data 的数字编码转为文字作为 value */
- const convertRegionToLabelValue = (data: any[]): any[] => {
- return data.map(item => ({
- value: item.label,
- label: item.label,
- children: item.children ? convertRegionToLabelValue(item.children) : undefined
- }));
- };
- const regionOptions = convertRegionToLabelValue(regionData);
- const regionProps = {
- checkStrictly: false,
- emitPath: true
- };
- /** 标签列表(从逗号分隔字符串解析) */
- const tagList = computed({
- get: () => form.tags ? form.tags.split(',').filter(Boolean) : [],
- set: (val: string[]) => { form.tags = val.join(','); }
- });
- const inputTagVisible = ref(false);
- const inputTagValue = ref('');
- const inputTagRef = ref();
- const thumbnailInputRef = ref<HTMLInputElement>();
- const thumbnailUploadRef = ref<UploadInstance>();
- const videoInputRef = ref<HTMLInputElement>();
- const thumbnailUploading = ref(false);
- const videoUploading = ref(false);
- const jobLevelOptions = ref<{ label: string; value: string }[]>([]);
- const jobTypeOptions = ref<{ label: string; value: string }[]>([]);
- const jobOptions = ref<{ label: string; value: string }[]>([]);
- const rules = reactive<FormRules>({
- name: [{ required: true, message: '请输入培训名称', trigger: 'blur' }],
- description: [{ required: true, message: '请输入描述', trigger: 'blur' }],
- jobLevel: [{ required: true, message: '请选择岗位级别', trigger: 'change' }],
- job: [{ required: true, message: '请选择岗位', trigger: 'change' }],
- jobType: [{ required: true, message: '请选择岗位类型', trigger: 'change' }],
- thumbnail: [{ required: true, message: '请上传培训封面', trigger: 'change' }],
- videoList: [{ required: true, type: 'array', min: 1, message: '请至少上传一个视频', trigger: 'change' }],
- addressDetail: [{ required: true, message: '请输入地址', trigger: 'blur' }],
- timeRange: [{ required: true, type: 'array', min: 2, message: '请选择培训时间', trigger: 'change' }],
- applyRange: [{ required: true, type: 'array', min: 2, message: '请选择报名时间', trigger: 'change' }]
- });
- const formatFileSize = (size: number) => {
- if (size >= 1024 * 1024 * 1024) {
- return `${(size / 1024 / 1024 / 1024).toFixed(2)}GB`;
- }
- if (size >= 1024 * 1024) {
- return `${(size / 1024 / 1024).toFixed(1)}MB`;
- }
- if (size >= 1024) {
- return `${(size / 1024).toFixed(1)}KB`;
- }
- return `${size}B`;
- };
- const formatDuration = (totalSeconds: number) => {
- const hours = Math.floor(totalSeconds / 3600);
- const minutes = Math.floor((totalSeconds % 3600) / 60);
- const seconds = totalSeconds % 60;
- return [hours, minutes, seconds].map((item) => item.toString().padStart(2, '0')).join(':');
- };
- const parseDuration = (duration?: string) => {
- if (!duration) return 0;
- const parts = duration.split(':').map((item) => Number(item));
- if (parts.length === 3) {
- return parts[0] * 3600 + parts[1] * 60 + parts[2];
- }
- if (parts.length === 2) {
- return parts[0] * 60 + parts[1];
- }
- return parts[0] || 0;
- };
- const calcTotalDuration = (videoList: any[]) => {
- const totalSeconds = videoList.reduce((sum, item) => sum + parseDuration(item.duration), 0);
- return totalSeconds > 0 ? formatDuration(totalSeconds) : '';
- };
- const getVideoDuration = (file: File) => new Promise<string>((resolve) => {
- const video = document.createElement('video');
- const objectUrl = URL.createObjectURL(file);
- video.preload = 'metadata';
- video.onloadedmetadata = () => {
- const duration = Number.isFinite(video.duration) ? Math.round(video.duration) : 0;
- URL.revokeObjectURL(objectUrl);
- resolve(formatDuration(duration));
- };
- video.onerror = () => {
- URL.revokeObjectURL(objectUrl);
- resolve('00:00:00');
- };
- video.src = objectUrl;
- });
- const loadSelectOptions = async () => {
- const [jobLevelRes, jobTypeRes, positionRes] = await Promise.all([
- getDicts('main_position_level'),
- getDicts('main_position_type'),
- listPosition({ pageNum: 1, pageSize: 999 })
- ]);
- jobLevelOptions.value = (jobLevelRes.data || []).map((item: any) => ({
- label: item.dictLabel,
- value: item.dictLabel
- }));
- jobTypeOptions.value = (jobTypeRes.data || []).map((item: any) => ({
- label: item.dictLabel,
- value: item.dictLabel
- }));
- jobOptions.value = (positionRes.rows || []).map((item: any) => ({
- label: item.postName,
- value: item.postName
- }));
- };
- onMounted(async () => {
- await loadSelectOptions();
- if (props.initialData) {
- // 编辑模式: 回显数据
- form.id = props.initialData.id;
- form.name = props.initialData.name || '';
- form.thumbnail = props.initialData.thumbnail;
- form.thumbnailUrl = props.initialData.thumbnailUrl || '';
- form.description = props.initialData.description || '';
- form.jobLevel = props.initialData.jobLevel || '';
- form.job = props.initialData.job || '';
- form.jobType = props.initialData.jobType || '';
- form.sortOrder = props.initialData.sortOrder?.toString() || '';
- form.status = props.initialData.status ?? 1;
- form.organizer = props.initialData.organizer || '';
- form.tags = props.initialData.tags || '';
- // 视频列表
- if (props.initialData.videoList && props.initialData.videoList.length > 0) {
- form.videoList = props.initialData.videoList.map((v: any) => ({
- id: v.id,
- name: v.name,
- fileSize: v.fileSize,
- fileType: v.fileType,
- duration: v.duration,
- ossId: v.ossId,
- fileUrl: v.fileUrl,
- sortOrder: v.sortOrder
- }));
- }
- // 线下培训地址
- form.city = props.initialData.city || '上海市';
- form.area = props.initialData.area || '黄浦区';
- form.region = ['上海市', form.city || '上海市', form.area || '黄浦区'];
- form.addressDetail = props.initialData.addressDetail || '';
- // 时间范围
- if (props.initialData.trainingStartTime && props.initialData.trainingEndTime) {
- form.timeRange = [props.initialData.trainingStartTime, props.initialData.trainingEndTime];
- }
- if (props.initialData.applyStartTime && props.initialData.applyEndTime) {
- form.applyRange = [props.initialData.applyStartTime, props.initialData.applyEndTime];
- }
- }
- });
- const handleUploadThumbnail = () => {
- thumbnailInputRef.value?.click();
- };
- const handleThumbnailFileChange = async (event: Event) => {
- const target = event.target as HTMLInputElement;
- const rawFile = target.files?.[0];
- if (!rawFile) {
- return;
- }
- if (!['image/jpeg', 'image/png'].includes(rawFile.type)) {
- ElMessage.error('请上传 JPG、PNG 格式图片');
- target.value = '';
- return;
- }
- if (rawFile.size / 1024 / 1024 > 10) {
- ElMessage.error('图片不能超过10M');
- target.value = '';
- return;
- }
- thumbnailUploading.value = true;
- try {
- const res = await uploadTrainingFile(rawFile);
- form.thumbnail = res.data?.ossId;
- form.thumbnailUrl = res.data?.url || URL.createObjectURL(rawFile);
- formRef.value?.clearValidate('thumbnail');
- ElMessage.success('封面上传成功');
- } catch (error) {
- ElMessage.error('封面上传失败');
- } finally {
- target.value = '';
- thumbnailUploading.value = false;
- }
- };
- const handleUploadVideo = () => {
- videoInputRef.value?.click();
- };
- const handleVideoFileChange = async (event: Event) => {
- const target = event.target as HTMLInputElement;
- const rawFile = target.files?.[0];
- if (!rawFile) {
- return;
- }
- const extension = rawFile.name.split('.').pop()?.toLowerCase() || '';
- const allowTypes = ['mp4', 'avi', 'wmv', 'mov', 'flv', 'rmvb', '3gp', 'm4v', 'mkv'];
- if (!allowTypes.includes(extension)) {
- ElMessage.error('视频格式不支持');
- target.value = '';
- return;
- }
- if (rawFile.size / 1024 / 1024 / 1024 > 10) {
- ElMessage.error('视频不能超过10G');
- target.value = '';
- return;
- }
- videoUploading.value = true;
- try {
- const duration = await getVideoDuration(rawFile);
- const res = await uploadTrainingFile(rawFile);
- form.videoList.push({
- name: res.data?.originalName || res.data?.fileName || rawFile.name,
- fileSize: formatFileSize(rawFile.size),
- fileType: extension.toUpperCase(),
- duration,
- ossId: res.data?.ossId,
- fileUrl: res.data?.url,
- sortOrder: form.videoList.length + 1
- });
- formRef.value?.clearValidate('videoList');
- ElMessage.success('视频上传成功');
- } catch (error) {
- ElMessage.error('视频上传失败');
- } finally {
- target.value = '';
- videoUploading.value = false;
- }
- };
- const removeVideo = (index: number) => {
- form.videoList.splice(index, 1);
- };
- const handleCloseTag = (tag: string) => {
- const list = tagList.value.filter(t => t !== tag);
- tagList.value = list;
- };
- const showInputTag = () => {
- inputTagVisible.value = true;
- nextTick(() => {
- inputTagRef.value?.focus();
- });
- };
- const handleInputTagConfirm = () => {
- if (inputTagValue.value) {
- const list = [...tagList.value, inputTagValue.value];
- tagList.value = list;
- }
- inputTagVisible.value = false;
- inputTagValue.value = '';
- };
- const handleSubmit = async () => {
- if (!formRef.value) return;
- await formRef.value.validate((valid) => {
- if (valid) {
- if (props.type === 'offline') {
- const [province, cityLevel, district] = form.region || [];
- // 合并省+市到 city(直辖市跳过"市辖区")
- const mergedCity = cityLevel && !cityLevel.includes('市辖区')
- ? `${province}${cityLevel}`
- : province;
- form.city = mergedCity || '上海市';
- form.area = district || '';
- }
- // 构造提交数据
- const submitData: any = {
- id: form.id,
- name: form.name,
- description: form.description,
- thumbnail: form.thumbnail,
- jobLevel: form.jobLevel,
- job: form.job,
- jobType: form.jobType,
- status: form.status
- };
- if (props.type === 'video') {
- submitData.sortOrder = form.sortOrder ? parseInt(form.sortOrder) : 0;
- submitData.duration = calcTotalDuration(form.videoList);
- submitData.videoList = form.videoList.map((v, i) => ({
- id: v.id,
- name: v.name,
- ossId: v.ossId,
- fileUrl: v.fileUrl,
- fileSize: v.fileSize,
- fileType: v.fileType,
- duration: v.duration,
- sortOrder: i + 1
- }));
- }
- if (props.type === 'offline') {
- submitData.city = form.city;
- submitData.area = form.area;
- submitData.addressDetail = form.addressDetail;
- submitData.organizer = form.organizer;
- submitData.tags = form.tags;
- if (form.timeRange && form.timeRange.length === 2) {
- submitData.trainingStartTime = form.timeRange[0];
- submitData.trainingEndTime = form.timeRange[1];
- }
- if (form.applyRange && form.applyRange.length === 2) {
- submitData.applyStartTime = form.applyRange[0];
- submitData.applyEndTime = form.applyRange[1];
- }
- }
- emit('submit', submitData);
- }
- });
- };
- </script>
- <style scoped lang="scss">
- .train-form {
- background-color: #f0f2f5;
- min-height: calc(100vh - 84px);
- padding: 20px 40px;
- @media (max-width: 768px) {
- padding: 10px;
- }
- .form-breadcrumb {
- margin-bottom: 20px;
- }
- .form-header {
- margin-bottom: 30px;
- }
- .form-wrapper {
- max-width: 1000px;
- margin: 0 auto;
- background-color: #fff;
- padding: 40px 60px;
- border: 2px solid #409eff;
- border-radius: 4px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
- @media (max-width: 768px) {
- padding: 20px;
- }
- }
- .submit-btn {
- padding: 0 30px;
- height: 36px;
- background-color: #409eff;
- border-color: #409eff;
- }
- .back-btn {
- padding: 0 30px;
- height: 36px;
- color: #606266;
- }
- .thumbnail-uploader {
- .avatar-uploader {
- width: 160px;
- height: 80px;
- border: 1px dashed #d9d9d9;
- border-radius: 4px;
- cursor: pointer;
- position: relative;
- overflow: hidden;
- display: flex;
- align-items: center;
- justify-content: center;
- &:hover {
- border-color: #409eff;
- }
- }
- .avatar-preview {
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
- .avatar-uploader-icon {
- font-size: 20px;
- color: #8c939d;
- }
- }
- .video-table {
- :deep(.el-table__header) {
- background-color: #f7f8fa;
- }
- }
- .address-container {
- display: flex;
- align-items: flex-start;
- gap: 12px;
- width: 100%;
- max-width: 720px;
- .region-cascader {
- width: 240px;
- flex-shrink: 0;
- }
- .address-detail-input {
- width: 400px;
- flex: 1;
- }
- }
- :deep(.el-form-item__label) {
- color: #4e5969;
- font-weight: 500;
- }
- }
- </style>
|