feat(course): 新增音视频与图文组件支持

- 新增 AudioComp.vue 组件,支持音频播放与设置
- 新增 EditorComp.vue 组件,集成富文本编辑器用于图文内容
- 修改 chooseFileList.vue,增加文件上传功能与类型适配
- 更新 createCourse.vue,完善课程章节内容管理逻辑
- 升级 useCourseData.js 和 useCreateCourseMaps.js,增强类型映射与数据结构
- 优化 BasicTable.vue,移除调试日志并调整样式
- 引入 quill 及相关插件依赖以支持富文本编辑功能
This commit is contained in:
陈昱达
2025-11-24 14:07:33 +08:00
parent 6528491334
commit cc1af6a11e
10 changed files with 526 additions and 100 deletions

View File

@@ -30,6 +30,10 @@
"moment": "^2.29.4", "moment": "^2.29.4",
"qrcode.vue": "^3.3.3", "qrcode.vue": "^3.3.3",
"qs": "^6.11.0", "qs": "^6.11.0",
"quill": "^2.0.3",
"quill-blot-formatter": "^1.0.5",
"quill-image-drop-module": "^1.0.3",
"quill-image-resize-module": "^3.0.0",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",
"vue": "^3.2.13", "vue": "^3.2.13",
"vue-cookies": "^1.8.2", "vue-cookies": "^1.8.2",

View File

@@ -25,8 +25,6 @@ const props = defineProps({
default: false, default: false,
}, },
}); });
console.log(props);
const paginationCopy = computed(() => { const paginationCopy = computed(() => {
return { return {
pageSize: props.pagination.pageSize, pageSize: props.pagination.pageSize,

View File

@@ -1,24 +1,21 @@
<script setup> <script setup>
import { reactive, onMounted, ref, h, watch } from "vue"; import { reactive, onMounted, ref, h, watch } from "vue";
import { import { ElButton, ElInput, ElUpload } from "element-plus";
ElButton,
ElInput,
ElDialog,
ElForm,
ElFormItem,
ElRadioGroup,
ElRadio,
ElInputNumber,
} from "element-plus";
import BasicTable from "@/components/BasicElTable/BasicTable.vue"; import BasicTable from "@/components/BasicElTable/BasicTable.vue";
import { getPageListByType } from "@/hooks/useCreateCourseMaps"; import { getPageListByType } from "@/hooks/useCreateCourseMaps";
import { getType, getMapsItem } from "@/hooks/useCreateCourseMaps";
const props = defineProps({}); import Cookies from "vue-cookies";
const props = defineProps({
resType: {
type: Number,
default: "",
},
});
const tableData = ref([]); const tableData = ref([]);
const form = reactive({ const form = reactive({
name: "", name: "",
resType: 10, resType: props.resType,
}); });
let pagination = reactive({ let pagination = reactive({
pageSize: 10, pageSize: 10,
@@ -74,7 +71,7 @@ const columns = [
isDrag: false, isDrag: false,
completeSetup: 0, completeSetup: 0,
setupTage: "", setupTage: "",
resType: 10, resType: props.resType,
}); });
}, },
}, },
@@ -108,25 +105,31 @@ const chooseItem = (type) => {
showDialog.value = false; showDialog.value = false;
emit("chooseItem", { emit("chooseItem", {
...dialogVideoForm, ...dialogVideoForm,
type: 10, type: props.resType,
}); });
// postData.content=this.cware.content; };
// this.cwareChange.content = deepClone(this.cware.content)
// if(this.cware.content.contentType==52){ const uploadData = reactive({
// if(this.cware.linkInfo.url==''){ headers: {
// this.$message.error("请填写外连URL地址"); "XBOE-Access-Token": Cookies.get("token"),
// return; },
// } data: {
// postData.content.content=JSON.stringify(this.cware.linkInfo); dir: "course",
// this.cwareChange.linkInfo = deepClone(this.cware.linkInfo) },
// }else if(this.cware.content.contentType==10 || this.cware.content.contentType==20){ actionUrl: process.env.VUE_APP_SYS_API + "/xboe/sys/xuploader/file/upload",
// if(this.cware.curriculumData.url==''){ });
// this.$message.error("请选择课件");
// return; const fileList = ref([]);
// } const uploadSuccess = (result) => {
// postData.content.content=JSON.stringify(this.cware.curriculumData); if (result.status === 200) {
// this.cwareChange.curriculumData = deepClone(this.cware.curriculumData) emit("chooseItem", {
// } ...dialogVideoForm,
name: result.result.displayName,
resType: props.resType,
...result.result,
});
fileList.value = [];
}
}; };
onMounted(() => { onMounted(() => {
@@ -135,20 +138,36 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="add-video" v-if="!isPreview && !isSetting"> <div class="add-video">
<div class="add-vide-header"> <div class="add-vide-header">
<div> <div style="display: flex; align-items: center">
<el-button>上传新视频</el-button> <el-upload
<span class="desc ml10">文件大小限制1G,支持的文件类型:mp4 </span> :action="uploadData.actionUrl"
:headers="uploadData.headers"
:data="uploadData.data"
:on-success="uploadSuccess"
:file-list="fileList"
>
<el-button v-if="[10, 20].includes(props.resType)" type="primary"
>上传新{{ getType(props.resType) }}</el-button
>
</el-upload>
<span class="desc ml10"
>文件大小限制{{
getMapsItem(props.resType).uploadSizeName
}},支持的文件类型:{{ getMapsItem(props.resType).fileType.join(",") }}
</span>
</div> </div>
<div> <div>
<el-input <el-input
style="width: 150px" style="width: 150px"
placeholder="请输入视频名称" :placeholder="`请输入${getType(props.resType)}名称`"
v-model="form.name" v-model="form.name"
clearable clearable
></el-input> ></el-input>
<el-button class="ml10" @click="getVideoList">查询</el-button> <el-button class="ml10" @click="getVideoList" type="primary"
>查询</el-button
>
</div> </div>
</div> </div>
<div class="mt10"> <div class="mt10">

View File

@@ -0,0 +1,106 @@
<script setup>
import {
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElRadio,
ElRadioGroup,
} from "element-plus";
defineOptions({
resType: 20,
});
const props = defineProps({
dialogVideoForm: {
type: Object,
default: () => ({
name: "",
filePath: "",
isDrag: true,
completeSetup: 0,
setupTage: 0,
}),
},
isPreview: {
type: Boolean,
default: false,
},
});
import { ref, watch } from "vue";
// Create a reactive copy of the prop for local modifications
const localDialogVideoForm = ref({ ...props.dialogVideoForm });
// Watch for changes in the prop and update the local copy
watch(
() => props.dialogVideoForm,
(newVal) => {
Object.assign(localDialogVideoForm.value, newVal);
},
{ deep: true }
);
// Emit updates to parent component
const emit = defineEmits(["update:dialogVideoForm"]);
// Update form values and emit changes
const updateFormValue = (field, value) => {
localDialogVideoForm.value[field] = value;
emit("update:dialogVideoForm", { ...localDialogVideoForm.value });
};
</script>
<template>
<el-form>
<el-form-item label="视频名称" v-if="!isPreview">
<el-input
v-model="localDialogVideoForm.name"
@update:modelValue="(val) => updateFormValue('name', val)"
></el-input>
</el-form-item>
<!-- Added video type prop -->
<audio
controls
style="width: 100%; max-height: 400px"
class="mb10"
:key="localDialogVideoForm.filePath"
:src="'http://home.hzer.xyz:9960/upload/' + localDialogVideoForm.filePath"
>
您的浏览器不支持video
</audio>
<el-form-item label="是否允许拖拽" v-if="!isPreview">
<el-radio-group
:model-value="localDialogVideoForm.isDrag"
@update:modelValue="(val) => updateFormValue('isDrag', val)"
size="small"
>
<el-radio :label="true" border></el-radio>
<el-radio :label="false" border></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="完成规则设置" v-if="!isPreview">
<el-radio-group
:model-value="localDialogVideoForm.completeSetup"
@update:modelValue="(val) => updateFormValue('completeSetup', val)"
>
<el-radio :label="0">默认(系统自动控制)</el-radio>
<el-radio :label="1">
按进度
<el-input-number
:disabled="localDialogVideoForm.completeSetup === 0"
:model-value="localDialogVideoForm.setupTage"
@update:modelValue="(val) => updateFormValue('setupTage', val)"
size="mini"
:min="0"
:max="100"
label="描述文字"
controls-position="right"
></el-input-number>
%
</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,160 @@
<script setup>
import { ElForm, ElFormItem, ElInput } from "element-plus";
import Quill from "quill";
import "quill/dist/quill.core.css";
import "quill/dist/quill.snow.css";
import "quill/dist/quill.bubble.css";
import { ImageDrop } from "quill-image-drop-module";
import BlotFormatter from "quill-blot-formatter";
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
// 注册Quill模块
Quill.register("modules/imageDrop", ImageDrop);
Quill.register("modules/blotFormatter", BlotFormatter);
const Size = Quill.import("attributors/style/size");
Size.whitelist = ["15px", "18px"];
Quill.register(Size, true);
// 只有在Parchment存在时才创建lineHeightAttributor
let lineHeightStyle = null;
try {
const Parchment = Quill.import("parchment");
if (Parchment && Parchment.Attributor && Parchment.Attributor.Style) {
class lineHeightAttributor extends Parchment.Attributor.Style {}
lineHeightStyle = new lineHeightAttributor("lineHeight", "line-height", {
scope: Parchment.Scope.INLINE,
whitelist: ["1", "1.5", "2", "3", "4"],
});
Quill.register({ "formats/lineHeight": lineHeightStyle }, true);
}
} catch (e) {
console.warn("Failed to register lineHeight formatter:", e);
}
const toolbarOptions = [
["bold", "italic", "underline", "strike"],
["blockquote", "code-block"],
[{ list: "ordered" }, { list: "bullet" }],
[{ indent: "-1" }, { indent: "+1" }],
[{ size: [false, "18px"] }],
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }],
[{ align: [] }],
[{ lineheight: ["initial", "1", "1.5", "2", "3", "4"] }],
["clean"],
["link", "image"],
["selectPicture"],
];
// 随机数 八位
function uuid() {
return Math.random().toString(36).substring(2, 8);
}
const quillClass = "editor_" + uuid();
let quill = null;
defineOptions({
resType: 41,
});
const props = defineProps({
dialogVideoForm: {
type: Object,
default: () => ({
name: "",
filePath: "",
isDrag: true,
completeSetup: 0,
setupTage: 0,
}),
},
isPreview: {
type: Boolean,
default: false,
},
});
// Create a reactive copy of the prop for local modifications
const localDialogVideoForm = ref({ ...props.dialogVideoForm });
// Watch for changes in the prop and update the local copy
watch(
() => props.dialogVideoForm,
(newVal) => {
Object.assign(localDialogVideoForm.value, newVal);
},
{ deep: true }
);
// Emit updates to parent component
const emit = defineEmits(["update:dialogVideoForm"]);
// Update form values and emit changes
const updateFormValue = (field, value) => {
localDialogVideoForm.value[field] = value;
emit("update:dialogVideoForm", { ...localDialogVideoForm.value });
};
const editor = ref(null);
onMounted(() => {
nextTick(() => {
if (editor.value) {
quill = new Quill(`.${quillClass}`, {
modules: {
toolbar: toolbarOptions,
imageDrop: true,
blotFormatter: {
overlay: {
// 自定义图片调整大小的样式
},
},
},
theme: "snow",
});
// 如果有内容,设置内容
if (props.dialogVideoForm.filePath) {
quill.root.innerHTML = props.dialogVideoForm.filePath;
}
// 监听文本变化
quill.on("text-change", () => {
const content = quill.root.innerHTML;
updateFormValue("filePath", content);
});
}
});
});
onBeforeUnmount(() => {
if (quill) {
quill = null;
}
});
// 监听内容变化
watch(
() => props.dialogVideoForm.filePath,
(newContent) => {
if (quill && newContent !== quill.root.innerHTML) {
quill.root.innerHTML = newContent || "";
}
}
);
</script>
<template>
<el-form>
<el-form-item label="名称" v-if="!isPreview">
<el-input
v-model="localDialogVideoForm.name"
@update:model-value="(val) => updateFormValue('name', val)"
></el-input>
</el-form-item>
<div :class="quillClass" ref="editor" style="min-height: 300px"></div>
</el-form>
</template>
<style scoped lang="scss"></style>

View File

@@ -8,6 +8,9 @@ import {
ElRadioGroup, ElRadioGroup,
} from "element-plus"; } from "element-plus";
defineOptions({
resType: 10,
});
const props = defineProps({ const props = defineProps({
dialogVideoForm: { dialogVideoForm: {
type: Object, type: Object,

View File

@@ -27,6 +27,7 @@ export function useCourseData() {
createTime: "", createTime: "",
chooseIndex: "", chooseIndex: "",
sectionIndex: "", sectionIndex: "",
resType: "",
}); });
const tableColumns = [ const tableColumns = [
{ {
@@ -350,6 +351,13 @@ export function useCourseData() {
fun: "addAssessment", fun: "addAssessment",
}, },
]; ];
// 添加章
const addChapter = () => {
courseList.value.push({
title: "视频课程名称",
data: [],
});
};
return { return {
courseMetadata, courseMetadata,
@@ -357,5 +365,6 @@ export function useCourseData() {
courseActionButtons, courseActionButtons,
executeCourseOperation, executeCourseOperation,
tableColumns, tableColumns,
addChapter,
}; };
} }

View File

@@ -1,20 +1,138 @@
import apiCourseFile from "@/api/modules/courseFile"; import apiCourseFile from "@/api/modules/courseFile";
const contentTypeMaps = { // const contentTypeMaps = {
10: "视频", // 10: "视频",
20: "音频", // 20: "音频",
30: "图片", // 30: "图片",
40: "文档", // 40: "文档",
41: "图文", // 41: "图文",
50: "scorm", // 50: "scorm",
52: "外链", // 52: "外链",
60: "作业", // 60: "作业",
61: "考试", // 61: "考试",
62: "评估", // 62: "评估",
90: "其它", // 90: "其它",
}; // };
const contentTypeMaps = [
{
type: 10,
name: "视频",
icon: "icon-video",
// 1G B
uploadSize: 1024 * 1024 * 1024,
uploadSizeName: "1G",
// 文件类型
fileType: ["mp4"],
},
{
type: 20,
name: "音频",
icon: "icon-audio",
// 1G B
uploadSize: 1024 * 1024 * 1024,
uploadSizeName: "1G",
// 文件类型
fileType: ["mp3"],
},
{
type: 30,
name: "图片",
icon: "icon-image",
// 1G B
uploadSize: 1024 * 1024 * 1024,
uploadSizeName: "1G",
// 文件类型
fileType: ["png", "jpg", "jpeg", "gif"],
},
{
type: 40,
name: "文档",
icon: "icon-file",
// 1G B
uploadSize: 1024 * 1024 * 1024,
uploadSizeName: "1G",
// 文件类型
fileType: ["doc", "docx", "xls", "xlsx", "ppt", "pptx", "pdf"],
},
{
type: 41,
name: "图文",
icon: "icon-file",
// 1G B
uploadSize: 1024 * 1024 * 1024,
uploadSizeName: "1G",
// 文件类型
fileType: ["doc", "docx", "xls", "xlsx", "ppt", "pptx", "pdf"],
},
{
type: 50,
name: "scorm",
icon: "icon-file",
// 1G B
uploadSize: 1024 * 1024 * 1024,
uploadSizeName: "1G",
// 文件类型
fileType: ["zip"],
},
{
type: 52,
name: "外链",
icon: "icon-file",
// 1G B
uploadSize: 1024 * 1024 * 1024,
uploadSizeName: "1G",
// 文件类型
fileType: ["zip"],
},
{
type: 60,
name: "作业",
icon: "icon-file",
// 1G B
uploadSize: 1024 * 1024 * 1024,
uploadSizeName: "1G",
// 文件类型
fileType: ["zip"],
},
{
type: 61,
name: "考试",
icon: "icon-file",
// 1G B
uploadSize: 1024 * 1024 * 1024,
uploadSizeName: "1G",
// 文件类型
fileType: ["zip"],
},
{
type: 62,
name: "评估",
icon: "icon-file",
// 1G B
uploadSize: 1024 * 1024 * 1024,
uploadSizeName: "1G",
// 文件类型
fileType: ["zip"],
},
{
type: 90,
name: "其它",
icon: "icon-file",
// 1G B
uploadSize: 1024 * 1024 * 1024,
uploadSizeName: "1G",
// 文件类型
fileType: ["zip"],
},
];
export function getType(type) { export function getType(type) {
return contentTypeMaps[type]; const item = contentTypeMaps.filter((item) => item.type === type)[0];
return item ? item.name : "";
}
export function getMapsItem(type) {
return contentTypeMaps.filter((item) => item.type === type)[0];
} }
// 根据不同type 类型查询 媒体 视频等列表 // 根据不同type 类型查询 媒体 视频等列表

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import dragCollapse from "./dragCollapse.vue"; import dragCollapse from "./dragCollapse.vue";
import { ElButton, ElCheckbox, ElDialog } from "element-plus"; import { ElButton, ElCheckbox, ElDialog, ElMessageBox } from "element-plus";
import dragTable from "./dragTable.vue"; import dragTable from "./dragTable.vue";
import { ref, reactive } from "vue"; import { ref, reactive } from "vue";
defineOptions({ defineOptions({
@@ -9,42 +9,34 @@ defineOptions({
import { useCourseData } from "@/hooks/useCourseData"; import { useCourseData } from "@/hooks/useCourseData";
import chooseFileList from "@/components/CreatedCourse/chooseFileList.vue"; import chooseFileList from "@/components/CreatedCourse/chooseFileList.vue";
import VideoComp from "@/components/CreatedCourse/preview/VideoComp.vue"; import VideoComp from "@/components/CreatedCourse/preview/VideoComp.vue";
import AudioComp from "@/components/CreatedCourse/preview/AudioComp.vue";
const mapComponents = { import EditorComp from "@/components/CreatedCourse/preview/EditorComp.vue";
VideoComp, import { getType } from "@/hooks/useCreateCourseMaps";
}; const mapComponents = [VideoComp, AudioComp, EditorComp];
// 使用课程数据hook // 使用课程数据hook
const { courseMetadata, courseList, courseActionButtons } = useCourseData(); const { courseMetadata, courseList, courseActionButtons, addChapter } =
const isSetting = ref(false); useCourseData();
const isPreview = ref(false); const isPreview = ref(false);
const chooseItemData = ref({}); const chooseItemData = ref({});
const showSettingDialog = ref(false); const showSettingDialog = ref(false);
// 定义表格列 // 定义表格列
// 添加章
const addChapter = () => {
courseList.value.push({
title: "视频课程名称",
data: [],
});
};
const showDialog = ref(false); const showDialog = ref(false);
// 课程操作映射 // 课程操作映射
const courseOperations = { const courseOperations = {
addVideo: (index) => { addVideo: () => {
courseMetadata.resType = 10;
showDialog.value = true; showDialog.value = true;
}, },
addAudio: () => { addAudio: () => {
console.log("添加音频功能调用"); courseMetadata.resType = 20;
}, showDialog.value = true;
addDocument: () => {
console.log("添加文档功能调用");
}, },
addDocument: () => {},
addImageText: () => { addImageText: () => {
console.log("添加图文功能调用"); courseMetadata.resType = 41;
chooseItemData.value.resType = 41;
showSettingDialog.value = true;
}, },
addExternalLink: () => { addExternalLink: () => {
console.log("添加外部链接功能调用"); console.log("添加外部链接功能调用");
@@ -62,7 +54,6 @@ const courseOperations = {
console.log("添加评估功能调用"); console.log("添加评估功能调用");
}, },
}; };
// 执行课程操作 // 执行课程操作
const executeCourseOperation = (operationName, data) => { const executeCourseOperation = (operationName, data) => {
courseMetadata.chooseIndex = data; courseMetadata.chooseIndex = data;
@@ -74,24 +65,21 @@ const executeCourseOperation = (operationName, data) => {
console.warn(`未找到操作: ${operationName}`); console.warn(`未找到操作: ${operationName}`);
} }
}; };
const chooseItem = (data) => { const chooseItem = (data) => {
console.log(data);
chooseItemData.value = data; chooseItemData.value = data;
console.log(chooseItemData.value);
showSettingDialog.value = true; showSettingDialog.value = true;
}; };
// 保存
const saveContent = () => { const saveContent = () => {
console.log(chooseItemData.value);
if (courseMetadata.selectionIndex !== null) { if (courseMetadata.selectionIndex !== null) {
courseList.value[courseMetadata.chooseIndex].data[
courseMetadata.selectionIndex
] = chooseItemData.value;
} else { } else {
console.log(courseList.value, courseMetadata); courseList.value[courseMetadata.chooseIndex].data.push({
resType: courseMetadata.resType,
courseList.value[courseMetadata.chooseIndex].data.push( ...chooseItemData.value,
chooseItemData.value });
);
} }
showDialog.value = false; showDialog.value = false;
showSettingDialog.value = false; showSettingDialog.value = false;
@@ -99,7 +87,16 @@ const saveContent = () => {
const deleteRow = (data) => { const deleteRow = (data) => {
courseMetadata.chooseIndex = data.index; courseMetadata.chooseIndex = data.index;
courseMetadata.selectionIndex = data.selectionIndex; courseMetadata.selectionIndex = data.selectionIndex;
chooseItemData.value = data.record; ElMessageBox.confirm(`确定删除${data.record.name}吗?`, "删除确认", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "error",
}).then(() => {
courseList.value[courseMetadata.chooseIndex].data.splice(
courseMetadata.selectionIndex,
1
);
});
}; };
const settingRow = (data) => { const settingRow = (data) => {
courseMetadata.chooseIndex = data.index; courseMetadata.chooseIndex = data.index;
@@ -164,18 +161,29 @@ const previewRow = (data) => {
</div> </div>
<!-- 选择文件列表--> <!-- 选择文件列表-->
<el-dialog v-model="showDialog" title="请选择操作"> <el-dialog v-model="showDialog" :title="getType(courseMetadata.resType)">
<chooseFileList @chooseItem="chooseItem"></chooseFileList> <chooseFileList
v-if="showDialog"
@chooseItem="chooseItem"
:resType="courseMetadata.resType"
></chooseFileList>
</el-dialog> </el-dialog>
<!-- 设置预览弹窗 --> <!-- 设置预览弹窗 -->
<el-dialog v-model="showSettingDialog" title="请选择操作"> <el-dialog
<component v-model="showSettingDialog"
v-for="item in mapComponents" :title="isPreview ? '预览' : getType(chooseItemData.resType)"
:is="item" >
v-model:dialogVideoForm="chooseItemData" <div v-for="item in mapComponents">
:isPreview="isPreview" {{ chooseItemData.resType }}, {{ item }}
></component> <component
v-if="Number(chooseItemData.resType) === item.resType"
:is="item"
v-model:dialogVideoForm="chooseItemData"
:isPreview="isPreview"
></component>
</div>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
<el-button @click="showSettingDialog = false">取消</el-button> <el-button @click="showSettingDialog = false">取消</el-button>

View File

@@ -163,9 +163,10 @@ const renderNameColumn = () => {
}, },
style: { style: {
border: "1px solid #d9d9d9", border: "1px solid #d9d9d9",
outline: "none",
borderRadius: "4px", borderRadius: "4px",
padding: "4px 11px", padding: "4px 11px",
width: "200px", width: "80%",
}, },
}), }),
h( h(