feat(course): 实现课程标签管理功能

- 新增课程标签API模块,支持标签分页查询、创建、修改状态等操作
- 开发课程标签组件,支持标签搜索、创建、删除和数量限制
- 集成标签组件到专业模式页面,替换原有标签选择器
- 优化课程创建组件,重构表单状态管理和操作流程
- 升级Element Plus组件版本,支持el-select-v2等新组件
- 添加lodash依赖用于防抖搜索功能
- 调整样式和布局,优化标签显示和交互体验
This commit is contained in:
陈昱达
2025-11-26 19:00:06 +08:00
parent 2158c7f0f1
commit 8a20689aeb
11 changed files with 1019 additions and 230 deletions

View File

@@ -26,6 +26,7 @@
"html2canvas": "^1.4.1",
"jquery": "^3.6.1",
"json-bigint": "^1.0.0",
"lodash": "^4.17.21",
"mitt": "^3.0.0",
"moment": "^2.29.4",
"pdf-vue3": "^1.0.12",

View File

@@ -0,0 +1,64 @@
/**课程标签模块的相关处理*/
import ajax from "./xajax.js";
/**
* 分页查询:标签列表
* @param {Object} query
*/
const portalPageList = function (query) {
return ajax.post("/systemapi/xboe/m/coursetag/page", query);
};
//改变标签的公共属性
const changeTagPublic = function (row) {
// 返回 Promise 的 API 调用
return ajax.post("/systemapi/xboe/m/coursetag/changePublicStatus", {
id: row.id,
isPublic: row.isPublic,
});
};
//改变标签的热点属性
const changeTagHot = function (row) {
// 返回 Promise 的 API 调用
return ajax.post("/systemapi/xboe/m/coursetag/changeHotStatus", {
id: row.id,
isHot: row.isHot,
});
};
//查询指定id的标签关联的所有课程
const showCourseByTag = function (query) {
return ajax.post("/systemapi/xboe/m/coursetag/showCourseByTag", query);
};
//解除指定id的课程和某个标签之间的关联关系
const unbindCourseTagRelation = function (params) {
return ajax.post("/systemapi/xboe/m/coursetag/unbind", params);
};
//编辑课程:标签模糊查询
const searchTags = function (params) {
return ajax.post("/systemapi/xboe/m/coursetag/searchTags", params);
};
//编辑课程:创建标签(与当前课程关联)
const createTag = function (params) {
return ajax.post("/systemapi/xboe/m/coursetag/createTag", params);
};
//获取最新前10个热点标签
const getHotTagList = function (params) {
return ajax.post("/systemapi/xboe/m/coursetag/getHotTagList", params);
};
export default {
portalPageList,
changeTagPublic,
changeTagHot,
showCourseByTag,
unbindCourseTagRelation,
searchTags,
createTag,
getHotTagList,
};

View File

@@ -0,0 +1,121 @@
/**对应用户中心新的接口*/
import ajax from "./xajax";
//const baseURL = process.env.VUE_APP_CESOURCE_BASE_API;
const baseURL = "/userbasic";
/**【未使用】用于本地测试*/
const login = function () {
return ajax.post(baseURL + "/org/userParentOrg", {});
};
/** 2023年6月新增加退出接口*/
const logout = function () {
return ajax.postJson(baseURL + "/logout", { from: "pc" });
};
/**
* 【此接口已经不再使用】获取用户的组织机构
* organization_id
*/
const userParentOrg = function () {
return ajax.post(baseURL + "/org/userParentOrg", {});
};
/**
* /userbasic/org/list
* 根据关键字查询机构
*/
const findOrgsByKeyword = function (keyword) {
console.log(12312);
return ajax.postJson(baseURL + "/org/list", { keyword });
};
/**
* 【此接口已经不再使用】
*/
const findOrgTreeByOrgId = function (orgId) {
return ajax.postJson(baseURL + "/org/childOrgs", { orgId });
};
/** 获取机构信息 */
const getOrgInfo = function (orgId) {
return ajax.postJson(baseURL + "/org/info", { orgId });
};
/**【已接口已经不再使用】根据用户id获取用户的信息*/
const getUserInfoById = function (id) {
return ajax.postJson(baseURL + "/user/list", { id });
};
/**
* https://u-pre.boe.com/userbasic/audience/userAudiences
* 【当前代码中未查询到】获取当前用户受众信息
*/
const getUserCrowds = function () {
return ajax.postJson(baseURL + "/audience/userAudiences", {});
};
/**
* 获取用户过滤后的受众,只是查询已发布的
* {"page":1,pageSize:100,"keyword":""}
*/
const getUserAudiences = function (data) {
return ajax.postJson(baseURL + "/audience/userAudiencesFilter", data);
};
/**
* 重要接口获取hrbp数据课程审核。
* 此接口中的问题返回的机构名称namePath要是orgId的邮件中体现
*/
const getOrgHrbpInfo = function (orgId) {
return ajax.postJson(baseURL + "/org/orgHrbpInfo", { orgId });
};
/**
* 修改密码已转化为userbasic接口
* {newPassword:'',oldPassword:''}
*/
const modifyPassword = function (data) {
return ajax.postJson(baseURL + "/user/resetPassword", data);
};
/**获取加入的受众的id集合*/
const getInAudienceIds = function () {
return ajax.post(baseURL + "/audience/audienceByUser", {});
};
/**
* 2023年6月新增加
* 更新用户信息,当前只是列新三个信息,根据aid来更新
* aid
* avatar
* sign
*/
const updateUser = function (data) {
return ajax.postJson(baseURL + "/user/updateUserMessage", data);
};
/**
* 2023年6月新增加
* 根据用户的id集合获取用户的姓名工号头像组织机构签名等信息
* ids: 用户的id数组集合
*/
const getUsersByIds = function (ids) {
return ajax.postJson(baseURL + "/user/getUserMessageToDai", ids);
};
export default {
userParentOrg,
findOrgsByKeyword,
getOrgInfo,
findOrgTreeByOrgId,
getUserInfoById,
getUserCrowds,
getUserAudiences,
getOrgHrbpInfo,
modifyPassword,
getInAudienceIds,
getUsersByIds,
updateUser,
logout,
};

View File

@@ -737,6 +737,7 @@ textarea {
}
.el-select,
.el-select-v2,
.el-cascader {
width: 100%;
}

View File

@@ -30,6 +30,13 @@ export function useCourseData() {
sectionIndex: "",
resType: 0,
selectionIndex: null,
// 添加课程操作相关状态
isPreview: false,
showSettingDialog: false,
isNext: true, // 添加操作的时候 弹窗是否弹出对应类型表单
showTablePreview: false,
showDialog: false,
classId: "",
});
// 课程列表数据
@@ -112,4 +119,4 @@ export function useCourseData() {
courseActionButtons,
addChapter,
};
}
}

View File

@@ -2,49 +2,47 @@ import { reactive, ref } from "vue";
/**
* 课程表单相关hook
* @returns
* @returns
*/
export function useCourseForm() {
// 表单相关
const formRef = ref();
// 表单相关
const formState = reactive({
courseName: "", // 课程名称
courseCategory: [], // 课程分类
resourceBelong: undefined, // 资源归属
lecturer: undefined, // 授课教师
targetGroup: "", // 目标人群
courseTags: [], // 课程标签
audience: undefined, // 受众
visibility: "Apple", // 可见性
coverIntro: "", // 封面介绍
courseValue: "", // 课程价值
courseIntro: "", // 课程简介
name: "",
device: 3,
crowds: [],
courseTags: [],
courseCategory: [],
orgName: "",
forUsers: "",
lecturer: [],
coverImg: "",
courseValue: "",
summary: "",
});
// 可见性选项
const visibilityOptions = [
{ label: "PC端可见", value: "Apple" },
{ label: "移动端可见", value: "Pear" },
{ label: "多端可见", value: "Orange", disabled: false },
{ label: "PC端可见", value: 1 },
{ label: "移动端可见", value: 2 },
{ label: "多端可见", value: 3 },
];
// 表单重置
const resetForm = (courseCoverurl, fileList) => {
const resetForm = (fileList) => {
if (formRef.value) {
formRef.value.resetFields();
}
if (courseCoverurl) {
courseCoverurl.value = "";
}
if (fileList) {
fileList.value = [];
}
};
return {
formRef,
formState,
visibilityOptions,
resetForm
resetForm,
formRef,
};
}
}

View File

@@ -0,0 +1,111 @@
import { ref, onMounted } from "vue";
import { getTeacherList } from "@/api/Lecturer";
import { getClassTree } from "@/api/modules/newApi";
import apiUserBasic from "@/api/modules/userbasic";
export function useFetchCourseList() {
const teachersList = ref([]);
const loading = ref(false);
// 分类列表
const sysTypeListMap = ref([]);
const sysTypeList = ref([]);
const courseTags = ref([]);
const curCourseId = ref("");
const orgList = ref([]);
const userGroupList = ref([]);
const fetchClassTree = async (data) => {
try {
const res = await getClassTree(data);
sysTypeListMap.value = res.result;
} catch (error) {
sysTypeListMap.value = [];
}
};
// 获取教师列表
const fetchTeacherList = async (data) => {
try {
loading.value = true;
const res = await getTeacherList({});
teachersList.value = res.result?.records || [];
} catch (error) {
console.error("获取讲师列表失败:", error);
teachersList.value = [];
} finally {
loading.value = false;
}
};
// 资源归属懒加载
const loadOrgNode = async (node, resolve) => {
try {
// 根节点level 0返回虚拟根
// if (node.level === 0) {
// resolve([{ name: "组织机构树", id: "-1" }]);
// return;
// }
// 一级节点level 1加载所有顶级组织
if (node.level === 0) {
const res = await apiUserBasic.findOrgsByKeyword("");
const treeList = (res.result || []).map((item) => ({
id: item.id,
name: item.name,
hrbpId: item.hrbpId,
children: [], // 假设有子节点(可根据后端字段优化)
}));
resolve(treeList);
return;
}
// 更深层级:根据 parentId 加载直接子组织
const parentId = node.data.id;
const res = await apiUserBasic.getOrgInfo(parentId);
if (res.status === 200 && Array.isArray(res.result?.directChildList)) {
const treeList = res.result.directChildList.map((item) => ({
id: item.id,
name: item.name,
hrbpId: item.hrbpId,
children: [], // 或根据 item.hasChildren / item.childCount > 0 动态设置
}));
resolve(treeList);
} else {
resolve([]); // 无子节点
}
} catch (error) {
console.error("加载组织树失败:", error);
// 出错时返回空数组,避免树组件卡住或报错
resolve([]);
}
};
// 受众列表
const getUserGroupList = async (data) => {
userGroupList.value = [];
try {
const res = await apiUserBasic.getUserAudiences(data);
res.result.list.forEach((item) => {
userGroupList.value.push({
id: item.id,
name: item.audienceName,
disabled: false,
});
});
} catch (error) {
console.error("获取用户组列表失败:", error);
userGroupList.value = [];
}
};
return {
fetchTeacherList,
fetchClassTree,
loadOrgNode,
getUserGroupList,
teachersList,
sysTypeListMap,
courseTags,
orgList,
curCourseId,
userGroupList,
sysTypeList,
};
}

View File

@@ -21,10 +21,12 @@ export function useMediaComponent(props, emit) {
// Update form values and emit changes
const updateFormValue = (field, value) => {
localDialogVideoForm.value[field] = value;
emit("update:dialogVideoForm", { ...localDialogVideoForm.value });
if (emit) {
emit("update:dialogVideoForm", { ...localDialogVideoForm.value });
}
};
const fileBaseUrl = `${process.env.VUE_APP_BOE_API_URL}/upload`;
const fileBaseUrl = `${process.env.VUE_APP_BOE_API_URL}${process.env.VUE_APP_FILE_PATH}`;
return {
localDialogVideoForm,

View File

@@ -0,0 +1,440 @@
<template>
<div class="tag-container" @click="handleContainerClick">
<el-select
size="large"
style="width: 100%"
v-model="selectedTags"
multiple
filterable
value-key="id"
remote
reserve-keyword
:remote-method="debouncedSearch"
:loading="loading"
placeholder="回车创建新标签"
:no-data-text="'无此标签,按回车键创建'"
@remove-tag="handleTagRemove"
@change="handleSelectionChange"
@keyup.enter="handleEnterKey"
@keyup.delete="handleDeleteKey"
@focus="handleFocus"
ref="tagSelectRef"
>
<el-option
v-for="item in searchResults"
:key="item.id"
:label="item.tagName"
:value="item"
:disabled="isTagDisabled(item)"
/>
</el-select>
<!-- 添加标签计数显示 -->
<div class="tag-count">{{ selectedTags.length }}/5</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted, nextTick } from "vue";
import { debounce } from "lodash";
import { $message } from "@/utils/useMessage";
import apiCourseTag from "@/api/modules/courseTag.js";
import { useStore } from "vuex";
import { ElSelect, ElOption } from "element-plus";
// Props
const props = defineProps({
courseId: {
type: String,
required: true,
},
sysTypeList: {
type: Array,
required: true,
default: () => [],
},
maxTags: {
type: Number,
default: 5,
},
// 添加接收初始标签数据的props
initialTags: {
type: Array,
default: () => [],
},
});
// Emits
const emit = defineEmits(["change", "focus"]);
// Store
const store = useStore();
const userInfo = computed(() => store.getters.userInfo);
// Refs
const tagSelectRef = ref(null);
// Reactive data
const selectedTags = ref([]);
const searchResults = ref([]);
const loading = ref(false);
const tagMap = reactive(new Map());
const params = reactive({});
const tag = reactive({});
// 添加临时存储用于回滚
const previousTags = ref([]);
const displayTags = computed(() => {
return selectedTags.value
.map((tag) => (typeof tag === "object" ? tag : tagMap.get(tag)))
.filter(Boolean);
});
// 创建防抖搜索函数
let debouncedSearch = debounce(doSearch, 500);
// Lifecycle hooks
onMounted(() => {
console.log(
"----------sysTypeList.length---------->" + props.sysTypeList.length
);
console.log(
"----------sysTypeList.length---------->" + (props.sysTypeList.length === 0)
);
if (props.initialTags && props.initialTags.length > 0) {
selectedTags.value = props.initialTags;
searchResults.value = props.initialTags;
// 将初始标签添加到tagMap中确保删除功能正常
props.initialTags.forEach((tag) => {
if (tag.id) {
tagMap.set(tag.id, tag);
}
});
}
});
// Watchers
watch(
() => props.courseId,
(newVal) => {
resetTagState();
}
);
watch(
() => props.initialTags,
(newVal) => {
selectedTags.value = newVal || [];
searchResults.value = newVal || [];
// 清空旧缓存
tagMap.clear();
newVal.forEach((tag) => {
if (tag.id) tagMap.set(tag.id, tag);
});
}
);
watch(
() => props.sysTypeList,
() => {
// 只有在已选择分类且有焦点时才重新加载
if (
props.sysTypeList.length > 0 &&
tagSelectRef.value &&
tagSelectRef.value.visible
) {
doSearch("");
}
},
{ deep: true }
);
// Methods
// 新增:检查标签是否应该被禁用
function isTagDisabled(tag) {
// 如果标签已经被选中,不应该禁用(允许取消选择)
const isSelected = selectedTags.value.some(
(selectedTag) => selectedTag.id === tag.id
);
if (isSelected) {
return false;
}
// 如果标签未被选中且已达到最大数量,则禁用
return selectedTags.value.length >= props.maxTags;
}
// 新增:处理输入框获得焦点事件
async function handleFocus() {
previousTags.value = [...selectedTags.value];
// 当输入框获得焦点时,加载默认的搜索结果
if (props.sysTypeList.length > 0) {
await doSearch("");
}
emit("focus");
}
function handleContainerClick() {
// 容器点击时也触发焦点事件
emit("focus");
}
// 新增:重置标签状态的方法
function resetTagState() {
selectedTags.value = [];
searchResults.value = [];
tagMap.clear();
loading.value = false;
// 清空 params 对象
Object.keys(params).forEach((key) => delete params[key]);
}
function handleTagRemove(tagId) {
selectedTags.value = selectedTags.value.filter((id) => id !== tagId);
emit("change", displayTags.value);
clearInput();
}
function removeTag(tagId) {
handleTagRemove(tagId);
}
// 新增:处理删除键事件
function handleDeleteKey(event) {
// 如果输入框内容为空,不执行任何搜索
if (!event.target.value.trim()) {
searchResults.value = [];
}
}
//按回车键,创建新标签
function handleEnterKey(event) {
const inputVal = event.target.value?.trim();
if (!inputVal) return;
// 检查是否与已选择的标签重复
const isDuplicate = selectedTags.value.some(
(tag) => tag.tagName === inputVal
);
if (isDuplicate) {
$message.warning("该标签已存在,无需重复创建");
event.target.value = "";
return;
}
if (!isDuplicate && inputVal && selectedTags.value.length < props.maxTags) {
createNewTag(event.target.value.trim());
clearInput();
} else if (selectedTags.value.length >= props.maxTags) {
$message.warning("最多只能添加5个标签");
clearInput();
} else {
clearInput();
}
}
// 新增:处理选择变化事件
function handleSelectionChange(newValues) {
// 检查每个标签对象是否完整
newValues.forEach((tag, index) => {
if (!tag.tagName) {
console.error(`${index}个标签缺少tagName:`, tag);
}
});
// 检查数量限制
if (newValues.length > props.maxTags) {
$message.warning(`最多只能选择${props.maxTags}个标签`);
// 回滚到之前的状态
selectedTags.value = [...previousTags.value];
return;
}
// 更新前保存当前状态
previousTags.value = [...newValues];
emit("change", displayTags.value);
clearInput();
nextTick(() => {
if (tagSelectRef.value) {
tagSelectRef.value.visible = false;
}
});
}
function clearInput() {
if (tagSelectRef.value) {
const input = tagSelectRef.value.$refs.input;
if (input) {
input.value = "";
}
}
}
//创建新标签
async function createNewTag(tagName) {
// 标签不能超过八个字
if (tagName.length > 8) {
$message.error("标签不能超过8个字");
return;
}
// 检查标签是否在下拉框中已存在
const isExistInSearch = searchResults.value.some(
(tag) => tag.tagName === tagName
);
if (isExistInSearch) {
$message.warning("已存在此标签,请选择");
return;
}
// 首先检查是否与已选择的标签重复
const isDuplicate = selectedTags.value.some((tag) => tag.tagName === tagName);
if (isDuplicate) {
$message.warning("该标签已存在,无需重复创建");
return;
}
// 标签格式验证:仅支持中文、英文、数字、下划线、中横线
const tagPattern = /^[\u4e00-\u9fa5a-zA-Z0-9_-]+$/;
if (!tagPattern.test(tagName)) {
$message.error(
"标签名称仅支持中文、英文、数字、下划线(_)和中横线(-),不支持空格、点和特殊字符"
);
return;
}
// 添加标签数量限制检查
if (selectedTags.value.length >= props.maxTags) {
$message.warning("最多只能添加5个标签");
return;
}
loading.value = true;
try {
params.courseId = props.courseId;
params.tagName = tagName;
// 分类
if (props.sysTypeList.length > 0) {
params.sysType1 = props.sysTypeList[0]; //一级的id
}
if (props.sysTypeList.length > 1) {
params.sysType2 = props.sysTypeList[1]; //二级的id
}
if (props.sysTypeList.length > 2) {
params.sysType3 = props.sysTypeList[2]; //三级的id
}
const { result: newTag } = await apiCourseTag.createTag(params);
$message.success("标签创建成功");
selectedTags.value = [...selectedTags.value, newTag];
// 更新搜索结果的逻辑保持不变
searchResults.value = [newTag, ...searchResults.value];
tagMap.set(newTag.id, newTag);
emit("change", displayTags.value);
nextTick(() => {
// 强制重新设置selectedTags来触发更新
const tempTags = [...selectedTags.value];
selectedTags.value = [];
nextTick(() => {
selectedTags.value = tempTags;
});
if (tagSelectRef.value) {
tagSelectRef.value.visible = false;
}
});
} finally {
loading.value = false;
}
}
// 修改doSearch方法添加搜索结果为空时的提示
async function doSearch(query) {
// 不再在空查询时清空搜索结果
// if (!query.trim()) {
// searchResults.value = []
// return
// }
console.log("---- doSearch ------ query = " + query);
loading.value = true;
try {
// 获取 typeId取 sysTypeList 最后一个有效的值
const typeId =
props.sysTypeList.length > 2
? props.sysTypeList[2]
: props.sysTypeList.length > 1
? props.sysTypeList[1]
: props.sysTypeList.length > 0
? props.sysTypeList[0]
: null;
console.log(
"---- doSearch searchTags ------ query = " +
query +
" , typeId = " +
typeId
);
const { result: tags } = await apiCourseTag.searchTags({
tagName: query,
typeId: typeId,
});
console.log("-- searchTags 查询结果 tags = " + tags);
tags.forEach((item) => {
tagMap.set(item.id, item);
});
searchResults.value = tags;
// 当搜索结果为空时,提示用户可以按回车键创建标签
if (tags.length === 0) {
// $message.info('无此标签,按回车键创建')
}
} finally {
loading.value = false;
}
}
// 导出方法供外部调用
defineExpose({
removeTag,
});
</script>
<style scoped>
.tag-container {
position: relative;
}
/* 添加标签计数样式 */
.tag-count {
position: absolute;
right: 10px;
top: 47%;
transform: translateY(-40%);
font-size: 12px;
color: #999;
background: white;
padding: 0 5px;
pointer-events: none;
/* 添加高度限制 */
height: 25px;
line-height: 25px; /* 垂直居中文字 */
box-sizing: border-box; /* 确保padding包含在height内 */
}
::v-deep(.el-select__tags) {
display: flex;
flex-wrap: wrap;
align-items: center;
}
::v-deep(.el-tag) {
flex: 1 1 auto; /* 自动调整宽度 */ //min-width: 30%; /* 设置最小宽度 */
//max-width: 48%; /* 设置最大宽度,留出边距 */
box-sizing: border-box;
margin-right: 8px;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
justify-content: center;
text-align: center;
}
::v-deep(.el-select__input) {
flex: 1;
min-width: 60px;
}
</style>

View File

@@ -4,9 +4,7 @@ import { ElButton, ElCheckbox, ElDialog, ElMessageBox } from "element-plus";
import { $message } from "@/utils/useMessage";
import dragTable from "./dragTable.vue";
import { ref, watch } from "vue";
defineOptions({
name: "CreateCourse",
});
import { getType } from "@/hooks/useCreateCourseMaps";
import { useCourseData } from "@/hooks/useCourseData";
import chooseFileList from "@/components/CreatedCourse/chooseFileList.vue";
import VideoComp from "@/components/CreatedCourse/preview/VideoComp.vue";
@@ -18,7 +16,10 @@ import ScormComp from "@/components/CreatedCourse/preview/ScormComp.vue";
import PaperComp from "@/components/CreatedCourse/preview/PaperComp.vue";
import HomeWorkComp from "@/components/CreatedCourse/preview/HomeWorkComp.vue";
import AccessComp from "@/components/CreatedCourse/preview/AccessComp.vue";
import { getType } from "@/hooks/useCreateCourseMaps";
defineOptions({
name: "CreateCourse",
});
// 组件映射
const mapComponents = [
VideoComp,
AudioComp,
@@ -34,22 +35,16 @@ const mapComponents = [
// 使用课程数据hook
const { courseMetadata, courseList, courseActionButtons, addChapter } =
useCourseData();
const isPreview = ref(false);
const chooseItemData = ref({});
const showSettingDialog = ref(false);
// 添加操作的时候 弹窗是否弹出对应类型表单
const isNext = ref(true);
const showTablePreview = ref(false);
// 定义表格列
const showDialog = ref(false);
const classId = ref("");
const copyChooseItemData = ref({});
// 监听课程索引变化更新classId
watch(
() => courseMetadata.chooseIndex,
(newVal) => {
classId.value = courseList.value[newVal].id;
if (courseList.value[newVal]) {
courseMetadata.classId = courseList.value[newVal].id || "";
}
}
);
@@ -57,46 +52,46 @@ watch(
const courseOperations = {
addVideo: () => {
courseMetadata.resType = 10;
showDialog.value = true;
courseMetadata.showDialog = true;
},
addAudio: () => {
courseMetadata.resType = 20;
showDialog.value = true;
courseMetadata.showDialog = true;
},
addDocument: () => {
courseMetadata.resType = 40;
showDialog.value = true;
isNext.value = false;
courseMetadata.showDialog = true;
courseMetadata.isNext = false;
},
addImageText: () => {
courseMetadata.resType = 41;
chooseItemData.value.resType = 41;
showSettingDialog.value = true;
courseMetadata.showSettingDialog = true;
},
addExternalLink: () => {
courseMetadata.resType = 52;
chooseItemData.value.resType = 52;
showSettingDialog.value = true;
courseMetadata.showSettingDialog = true;
},
addScorm: () => {
courseMetadata.resType = 50;
showDialog.value = true;
isNext.value = false;
courseMetadata.showDialog = true;
courseMetadata.isNext = false;
},
addExam: () => {
courseMetadata.resType = 61;
showDialog.value = true;
isNext.value = false;
courseMetadata.showDialog = true;
courseMetadata.isNext = false;
},
addHomework: () => {
courseMetadata.resType = 60;
chooseItemData.value.resType = 60;
showSettingDialog.value = true;
courseMetadata.showSettingDialog = true;
},
addAssessment: () => {
courseMetadata.resType = 62;
chooseItemData.value.resType = 62;
showSettingDialog.value = true;
courseMetadata.showSettingDialog = true;
},
};
@@ -105,9 +100,10 @@ const executeCourseOperation = (operationName, data) => {
courseMetadata.chooseIndex = data;
courseMetadata.selectionIndex = null;
copyChooseItemData.value = {};
isPreview.value = false;
isNext.value = true;
courseMetadata.isPreview = false;
courseMetadata.isNext = true;
chooseItemData.value = {};
if (courseOperations[operationName]) {
courseOperations[operationName](data);
} else {
@@ -115,92 +111,130 @@ const executeCourseOperation = (operationName, data) => {
}
};
// 选择项目处理
const chooseItem = (data) => {
console.log(data);
chooseItemData.value = data;
if (!isNext.value) {
// 如果不需要下一步,则直接保存
if (!courseMetadata.isNext) {
saveContent();
return;
}
showSettingDialog.value = true;
courseMetadata.showSettingDialog = true;
};
// 预览项目处理
const choosePreviewItem = (data) => {
chooseItemData.value = data;
showSettingDialog.value = true;
isPreview.value = true;
courseMetadata.showSettingDialog = true;
courseMetadata.isPreview = true;
};
// 取消保存
const cancelSave = () => {
showSettingDialog.value = false;
courseMetadata.showSettingDialog = false;
// 恢复原始数据
chooseItemData.value = copyChooseItemData.value;
courseList.value[courseMetadata.chooseIndex].data[
courseMetadata.selectionIndex
] = chooseItemData.value;
console.log(chooseItemData);
};
// 保存
const saveContent = () => {
console.log(chooseItemData.value);
if (courseMetadata.selectionIndex !== null) {
if (
courseMetadata.chooseIndex !== null &&
courseMetadata.selectionIndex !== null &&
courseList.value[courseMetadata.chooseIndex] &&
courseList.value[courseMetadata.chooseIndex].data[
courseMetadata.selectionIndex
]
) {
courseList.value[courseMetadata.chooseIndex].data[
courseMetadata.selectionIndex
] = chooseItemData.value;
} else {
courseList.value[courseMetadata.chooseIndex].data.push({
resType: courseMetadata.resType,
...chooseItemData.value,
});
}
showDialog.value = false;
showSettingDialog.value = false;
// 可以调用保存方法 保存考试
};
// 保存内容
const saveContent = () => {
if (courseMetadata.selectionIndex !== null) {
// 更新已有项
if (
courseList.value[courseMetadata.chooseIndex] &&
courseList.value[courseMetadata.chooseIndex].data[
courseMetadata.selectionIndex
]
) {
courseList.value[courseMetadata.chooseIndex].data[
courseMetadata.selectionIndex
] = {
...courseList.value[courseMetadata.chooseIndex].data[
courseMetadata.selectionIndex
],
...chooseItemData.value,
};
}
} else {
// 添加新项
if (courseList.value[courseMetadata.chooseIndex]) {
courseList.value[courseMetadata.chooseIndex].data.push({
resType: courseMetadata.resType,
...chooseItemData.value,
});
}
}
courseMetadata.showDialog = false;
courseMetadata.showSettingDialog = false;
};
// 删除行
const deleteRow = (data) => {
courseMetadata.chooseIndex = data.index;
courseMetadata.selectionIndex = data.selectionIndex;
ElMessageBox.confirm(`确定删除${data.record.name}吗?`, "删除确认", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "error",
}).then(() => {
courseList.value[courseMetadata.chooseIndex].data.splice(
courseMetadata.selectionIndex,
1
);
});
})
.then(() => {
if (
courseList.value[courseMetadata.chooseIndex] &&
courseList.value[courseMetadata.chooseIndex].data
) {
courseList.value[courseMetadata.chooseIndex].data.splice(
courseMetadata.selectionIndex,
1
);
}
})
.catch(() => {
// 用户取消删除
});
};
// 设置行
const settingRow = (data) => {
courseMetadata.chooseIndex = data.index;
courseMetadata.selectionIndex = data.selectionIndex;
chooseItemData.value = data.record;
chooseItemData.value = { ...data.record }; // 创建副本避免直接引用
copyChooseItemData.value = JSON.parse(JSON.stringify(data.record));
isPreview.value = false;
showSettingDialog.value = true;
courseMetadata.isPreview = false;
courseMetadata.showSettingDialog = true;
};
// 预览行
const previewRow = (data) => {
courseMetadata.chooseIndex = data.index;
courseMetadata.selectionIndex = data.selectionIndex;
chooseItemData.value = data.record;
chooseItemData.value = { ...data.record }; // 创建副本避免直接引用
copyChooseItemData.value = JSON.parse(JSON.stringify(data.record));
isPreview.value = true;
showSettingDialog.value = true;
courseMetadata.isPreview = true;
courseMetadata.showSettingDialog = true;
};
// 自定义考试
const chooseCusExam = (data) => {
chooseItemData.value = data;
showSettingDialog.value = true;
courseMetadata.showSettingDialog = true;
};
// 下一步处理
const handleNext = () => {
console.log($message);
$message.success("213");
};
</script>
@@ -212,7 +246,7 @@ const handleNext = () => {
<span>创建时间{{ courseMetadata.createTime }}</span>
</div>
<div class="course-content">
<div style="padding: 10px">
<div class="course-actions">
<el-button @click="addChapter">添加章</el-button>
<el-checkbox style="margin-left: 10px">顺序学习</el-checkbox>
</div>
@@ -220,73 +254,85 @@ const handleNext = () => {
<div>
<dragCollapse v-model:courseList="courseList">
<template #title="{ course }">{{ course.title }}</template>
<template #desc="{ course }"
>若课程只有一个章节将不在学员端显示该章节名称</template
>
<template #desc="{ course }">
若课程只有一个章节将不在学员端显示该章节名称
</template>
<template #default="{ course, index }">
<div class="drag-course-btn-content">
<el-button
v-for="btn in courseActionButtons"
:key="btn.fun"
type="primary"
class="btn-item"
plain
@click="executeCourseOperation(btn.fun, index)"
>{{ btn.label }}</el-button
>
{{ btn.label }}
</el-button>
</div>
<div>
<!-- 修改添加 groupId tableId 属性以支持跨表格拖拽 -->
<!-- 添加 groupId tableId 属性以支持跨表格拖拽 -->
<dragTable
:data="course.data"
:group-id="'course-chapters'"
group-id="course-chapters"
:table-id="'chapter-' + index"
:index="index"
@delete="deleteRow"
@setting="settingRow"
@preview="previewRow"
></dragTable>
/>
</div>
</template>
</dragCollapse>
</div>
</div>
<!-- 选择文件列表-->
<el-dialog v-model="showDialog" :title="getType(courseMetadata.resType)">
<!-- 选择文件列表 -->
<el-dialog
v-model="courseMetadata.showDialog"
:title="getType(courseMetadata.resType)"
>
<chooseFileList
v-if="showDialog"
v-if="courseMetadata.showDialog"
@chooseItem="chooseItem"
@choosePreviewItem="choosePreviewItem"
:resType="courseMetadata.resType"
:showTablePreview="showTablePreview"
:showTablePreview="courseMetadata.showTablePreview"
@chooseCusExam="chooseCusExam"
></chooseFileList>
/>
</el-dialog>
<!-- 设置预览弹窗 -->
<!-- 设置预览弹窗 -->
<el-dialog
v-model="showSettingDialog"
:title="isPreview ? '预览' : getType(chooseItemData.resType)"
v-model="courseMetadata.showSettingDialog"
:title="
courseMetadata.isPreview ? '预览' : getType(chooseItemData.resType)
"
:fullscreen="chooseItemData.resType === 41"
>
<div style="max-height: 600px; overflow: auto">
<template v-for="item in mapComponents">
<div class="component-preview">
<template v-for="item in mapComponents" :key="item.name">
<component
v-if="
Number(chooseItemData.resType) === item.resType &&
showSettingDialog
courseMetadata.showSettingDialog
"
:is="item"
v-model:dialogVideoForm="chooseItemData"
:isPreview="isPreview"
:classId="classId"
></component>
:isPreview="courseMetadata.isPreview"
:classId="courseMetadata.classId"
/>
</template>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelSave()">取消</el-button>
<el-button type="primary" @click="saveContent()" v-if="!isPreview">
<el-button
type="primary"
@click="saveContent()"
v-if="!courseMetadata.isPreview"
>
保存
</el-button>
</div>
@@ -305,20 +351,32 @@ const handleNext = () => {
.create-course {
width: 100%;
padding: 10px 20px;
.course-header {
display: flex;
justify-content: space-between;
.title {
font-size: 16px;
font-weight: 600;
}
}
.course-actions {
padding: 10px;
}
.drag-course-btn-content {
padding: 0 10px;
.btn-item + .btn-item {
margin-left: 10px;
}
}
.component-preview {
max-height: 600px;
overflow: auto;
}
}
</style>

View File

@@ -1,5 +1,7 @@
<script setup>
import { ref, defineOptions, reactive, onMounted } from "vue";
import { ref, defineOptions, onMounted } from "vue";
import { Plus, Loading } from "@element-plus/icons-vue";
import courseTag from "./courseTag.vue";
import { $message } from "@/utils/useMessage";
import {
ElForm,
@@ -11,9 +13,11 @@ import {
ElInput,
ElCascader,
ElSelect,
ElSelectV2,
ElTreeSelect,
ElOption,
} from "element-plus";
import { getClassTree } from "@/api/modules/newApi";
import filecloud from "@/components/FileCloud/index.vue";
import FieldCloud from "@/components/FileCloud/index.vue";
import { useUpload } from "@/hooks/useUpload";
import { useCourseForm } from "@/hooks/useCourseForm";
import { useRouter } from "vue-router";
@@ -21,68 +25,58 @@ const router = useRouter();
defineOptions({
name: "ProfessionalMode",
});
// 使用上传hook
const { fileList, loading, courseCoverurl, handleChange, beforeUpload } =
useUpload();
const { fileList, loading, handleChange, beforeUpload } = useUpload();
// 使用表单hook
const { formRef, formState, visibilityOptions, resetForm } = useCourseForm();
import { useFetchCourseList } from "@/hooks/useFetchCourseList";
const {
fetchClassTree,
getUserGroupList,
teachersList,
sysTypeListMap,
courseTags,
loadOrgNode,
curCourseId,
userGroupList,
sysTypeList,
} = useFetchCourseList();
// 表单相关
const labelCol = { style: { width: "80px" } };
// 数据相关
const data = ref({
typeOption: [],
});
import { useMediaComponent } from "@/hooks/useMediaComponent";
const { fileBaseUrl } = useMediaComponent({});
const handleTagsChange = (tags) => {
console.log("父组件:", tags);
// 限制最多5个标签
if (tags.length > 5) {
this.$message.warning("最多只能选择5个标签");
// 强制限制为5个
tags = tags.slice(0, 5);
return;
}
let ids = "";
tags.forEach((tag) => {
console.log("父组件name : ", tag.tagName);
ids += tag.id + ",";
});
};
const onTagFocus = () => {};
// 文件选择对话框
const dlgFileChoose = ref({
show: false,
});
// 课程信息
const courseInfo = ref({
id: "",
name: "",
orderStudy: false,
type: 10,
orgId: "",
coverImg: "",
source: 1,
forUsers: "",
forScene: "",
value: "",
tags: "",
keywords: "",
device: 3,
status: 1,
summary: "",
overview: "",
visible: true,
refId: "",
refType: "",
});
const fileUrl = process.env.VUE_APP_BASE_API1 + process.env.VUE_APP_FILE_PATH;
const dlgFileShow = ref(false);
// 方法定义
const chooseFile = () => {
dlgFileChoose.value.show = true;
dlgFileShow.value = true;
};
const changeCourseImage = (img) => {
if (!img.path) {
return;
}
dlgFileChoose.value.show = false;
courseInfo.value.coverImg = img.path;
courseCoverurl.value = fileUrl + img.path;
dlgFileShow.value = false;
formState.coverImg = fileBaseUrl + img.path;
};
const choseChoose = () => {
dlgFileChoose.value.show = false;
dlgFileShow.value = false;
};
// 表单提交
@@ -103,18 +97,8 @@ const handleSubmit = () => {
// 表单重置
const handleReset = () => {
resetForm(courseCoverurl, fileList);
resetForm(fileList);
};
// API调用
const fetchApi = {
getClassTree: () => {
return getClassTree(1).then((res) => {
data.value.typeOption = res.result;
});
},
};
const next = () => {
// 注意这里的路由跳转需要正确引入和使用vue-router
router.push({
@@ -126,8 +110,10 @@ const next = () => {
},
});
};
// 调用接口
onMounted(() => {
fetchApi.getClassTree();
fetchClassTree(1);
getUserGroupList({ keyword: "", page: 1, pageSize: 500 });
});
</script>
@@ -148,12 +134,12 @@ onMounted(() => {
<div class="professional-mode-form">
<el-form-item
label="课程名称"
prop="courseName"
prop="name"
:rules="[{ required: true, message: '请输入课程名称' }]"
>
<el-input
size="large"
v-model="formState.courseName"
v-model="formState.name"
placeholder="请输入课程名称"
/>
</el-form-item>
@@ -167,7 +153,7 @@ onMounted(() => {
size="large"
v-model="formState.courseCategory"
placeholder="请选择课程分类"
:options="data.typeOption"
:options="sysTypeListMap"
:props="{
label: 'name',
value: 'id',
@@ -177,17 +163,19 @@ onMounted(() => {
<el-form-item
label="资源归属"
prop="resourceBelong"
prop="orgName"
:rules="[{ required: true, message: '请选择资源归属' }]"
>
<el-select
<el-tree-select
size="large"
v-model="formState.resourceBelong"
check-strictly
lazy
:load="loadOrgNode"
:render-after-expand="false"
v-model="formState.orgName"
:props="{ value: 'id', label: 'name', children: 'children' }"
placeholder="请选择资源归属"
>
<el-option label="选项1" value="1"></el-option>
<el-option label="选项2" value="2"></el-option>
</el-select>
/>
</el-form-item>
<el-form-item
@@ -195,67 +183,65 @@ onMounted(() => {
prop="lecturer"
:rules="[{ required: true, message: '请选择授课教师' }]"
>
<el-select
<el-select-v2
size="large"
filterable
multiple
v-model="formState.lecturer"
:options="teachersList"
placeholder="请选择授课教师"
>
<el-option label="教师1" value="1"></el-option>
<el-option label="教师2" value="2"></el-option>
</el-select>
</el-select-v2>
</el-form-item>
<el-form-item
label="目标人群"
prop="targetGroup"
prop="forUsers"
:rules="[{ required: true, message: '请输入目标人群' }]"
>
<el-input
size="large"
v-model="formState.targetGroup"
v-model="formState.forUsers"
placeholder="请输入目标人群"
/>
</el-form-item>
<el-form-item label="课程标签" prop="courseTags">
<el-select
size="large"
v-model="formState.courseTags"
multiple
filterable
allow-create
placeholder="请选择或输入课程标签"
>
<el-option label="标签1" value="标签1"></el-option>
<el-option label="标签2" value="标签2"></el-option>
</el-select>
<courseTag
:courseId="curCourseId"
:sysTypeList="sysTypeList"
:initialTags="courseTags"
@change="handleTagsChange"
@focus="onTagFocus"
style="width: 100%"
></courseTag>
</el-form-item>
<el-form-item label="受众" prop="audience">
<el-form-item label="受众" prop="crowds">
<el-select
size="large"
v-model="formState.audience"
v-model="formState.crowds"
placeholder="请选择受众"
multiple
value-key="id"
>
<el-option label="受众1" value="1"></el-option>
<el-option label="受众2" value="2"></el-option>
<el-option
v-for="item in userGroupList"
:key="item.id"
:label="item.name"
:value="item"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="可见性" prop="visibility">
<el-radio-group v-model="formState.visibility">
<el-form-item label="观看设置" prop="device">
<el-radio-group v-model="formState.device">
<el-radio
v-for="item in visibilityOptions"
:key="item.value"
:label="item.value"
>
<span
:style="{
color:
formState.visibility === item.value ? '#409eff' : '#000',
}"
>{{ item.label }}</span
>
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
@@ -279,18 +265,18 @@ onMounted(() => {
:on-change="handleChange"
>
<img
v-if="courseCoverurl"
:src="courseCoverurl"
v-if="formState.coverImg"
:src="formState.coverImg"
alt="avatar"
style="width: 100%"
/>
<div v-else>
<el-icon v-if="loading"><Loading /></el-icon>
<el-icon v-else><Plus /></el-icon>
<div v-else class="text-center">
<Loading v-if="loading" class="w30" />
<Plus v-else class="w30" />
<div class="el-upload-text">上传图片</div>
</div>
</el-upload>
<el-button type="primary" link @click="chooseFile"
<el-button type="primary" link @click="chooseFile" class="ml10"
>选择封面</el-button
>
</div>
@@ -316,13 +302,13 @@ onMounted(() => {
<el-form-item
label="课程简介"
prop="courseIntro"
prop="summary"
:rules="[{ required: true, message: '请输入课程简介' }]"
>
<el-input
type="textarea"
size="large"
v-model="formState.courseIntro"
v-model="formState.summary"
:rows="4"
placeholder="请输入课程简介"
/>
@@ -340,11 +326,11 @@ onMounted(() => {
</div>
</div>
</el-form>
<filecloud
:show="dlgFileChoose.show"
<field-cloud
:show="dlgFileShow"
@choose="changeCourseImage"
@close="choseChoose"
></filecloud>
></field-cloud>
</div>
</template>