合并分支:合并课程两步式向导与教学资源关联管理能力
This commit is contained in:
commit
9030d797f4
22 files changed
+1178
-291
No files matched your search
@@ -16,6 +16,8 @@ node_modules/
|
||||
**/node_modules/
|
||||
**/node_modules1/
|
||||
**/node_modules2/
|
||||
.m2repo/
|
||||
.npm-cache/
|
||||
*.class
|
||||
**/*.class
|
||||
|
||||
|
||||
+33
-2
@@ -3,6 +3,7 @@ package com.ruoyi.web.controller.train;
|
||||
import java.util.List;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
@@ -17,7 +18,9 @@ import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.train.domain.TrainCourse;
|
||||
import com.ruoyi.train.domain.dto.CourseResourceBindDTO;
|
||||
import com.ruoyi.train.domain.vo.CourseDetailVO;
|
||||
import com.ruoyi.train.service.ITrainCourseResourceService;
|
||||
import com.ruoyi.train.service.ITrainCourseService;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
|
||||
@@ -34,6 +37,9 @@ public class TrainCourseController extends BaseController
|
||||
@Autowired
|
||||
private ITrainCourseService trainCourseService;
|
||||
|
||||
@Autowired
|
||||
private ITrainCourseResourceService trainCourseResourceService;
|
||||
|
||||
/**
|
||||
* 查询课程信息列表 【P0】
|
||||
*/
|
||||
@@ -48,7 +54,7 @@ public class TrainCourseController extends BaseController
|
||||
|
||||
/**
|
||||
* 获取课程信息详细信息 【P0】
|
||||
* 返回结构:CourseDetailVO (包含课程基础字段与契约预留resources列表)
|
||||
* 返回结构:CourseDetailVO(包含课程基础字段与已关联资源列表)
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:course:query')")
|
||||
@GetMapping(value = "/{cId}")
|
||||
@@ -82,7 +88,8 @@ public class TrainCourseController extends BaseController
|
||||
{
|
||||
return AjaxResult.error(400, "课程学时必须为正整数");
|
||||
}
|
||||
return toAjax(trainCourseService.insertTrainCourse(trainCourse));
|
||||
int rows = trainCourseService.insertTrainCourse(trainCourse);
|
||||
return rows > 0 ? success(trainCourse) : error();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +112,30 @@ public class TrainCourseController extends BaseController
|
||||
return toAjax(trainCourseService.updateTrainCourse(trainCourse));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为课程绑定教学资源。
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:course:edit')")
|
||||
@Log(title = "课程教学资源", businessType = BusinessType.INSERT)
|
||||
@PostMapping("/{cId}/resources")
|
||||
public AjaxResult bindResource(@PathVariable("cId") String cId,
|
||||
@Validated @RequestBody CourseResourceBindDTO bindDTO)
|
||||
{
|
||||
return toAjax(trainCourseResourceService.bindResource(cId, bindDTO.getResourceId(), bindDTO.getSortNo()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除课程与教学资源的关联,不删除资源本身。
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('train:course:edit')")
|
||||
@Log(title = "课程教学资源", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{cId}/resources/{resourceId}")
|
||||
public AjaxResult unbindResource(@PathVariable("cId") String cId,
|
||||
@PathVariable("resourceId") String resourceId)
|
||||
{
|
||||
return toAjax(trainCourseResourceService.unbindResource(cId, resourceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除课程信息
|
||||
*/
|
||||
|
||||
+2
-1
@@ -64,7 +64,8 @@ public class TrainResourceController extends BaseController
|
||||
public AjaxResult add(@Validated @RequestBody TrainResource trainResource)
|
||||
{
|
||||
trainResource.setCreateBy(SecurityUtils.getUsername());
|
||||
return toAjax(trainResourceService.insertTrainResource(trainResource));
|
||||
int rows = trainResourceService.insertTrainResource(trainResource);
|
||||
return rows > 0 ? success(trainResource) : error();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,10 +43,6 @@ public class TrainCourse extends BaseEntity
|
||||
@Excel(name = "课程内容")
|
||||
private String courseContent;
|
||||
|
||||
/** 课程资源ID,多个资源以英文逗号分隔,对应 train_resource.resource_id */
|
||||
@Excel(name = "课程资源ID")
|
||||
private String resourceId;
|
||||
|
||||
/** 课程状态 (1:正常, 0:停用) */
|
||||
@Excel(name = "课程状态", readConverterExp = "1=正常,0=停用")
|
||||
private Integer status;
|
||||
@@ -184,16 +180,6 @@ public class TrainCourse extends BaseEntity
|
||||
return courseContent;
|
||||
}
|
||||
|
||||
public void setResourceId(String resourceId)
|
||||
{
|
||||
this.resourceId = resourceId;
|
||||
}
|
||||
|
||||
public String getResourceId()
|
||||
{
|
||||
return resourceId;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status)
|
||||
{
|
||||
this.status = status;
|
||||
@@ -224,7 +210,6 @@ public class TrainCourse extends BaseEntity
|
||||
.append("cDuration", getcDuration())
|
||||
.append("teachingObjective", getTeachingObjective())
|
||||
.append("courseContent", getCourseContent())
|
||||
.append("resourceId", getResourceId())
|
||||
.append("status", getStatus())
|
||||
.append("delFlag", getDelFlag())
|
||||
.append("createBy", getCreateBy())
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.ruoyi.train.domain.dto;
|
||||
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 课程绑定教学资源请求。
|
||||
*/
|
||||
public class CourseResourceBindDTO
|
||||
{
|
||||
@NotBlank(message = "资源ID不能为空")
|
||||
private String resourceId;
|
||||
|
||||
@Min(value = 0, message = "资源排序号不能小于0")
|
||||
private Integer sortNo;
|
||||
|
||||
public String getResourceId()
|
||||
{
|
||||
return resourceId;
|
||||
}
|
||||
|
||||
public void setResourceId(String resourceId)
|
||||
{
|
||||
this.resourceId = resourceId;
|
||||
}
|
||||
|
||||
public Integer getSortNo()
|
||||
{
|
||||
return sortNo;
|
||||
}
|
||||
|
||||
public void setSortNo(Integer sortNo)
|
||||
{
|
||||
this.sortNo = sortNo;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.ruoyi.train.domain.vo;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.ruoyi.train.domain.TrainCourse;
|
||||
import com.ruoyi.train.domain.TrainResource;
|
||||
|
||||
/**
|
||||
* 课程详情视图对象 (API Contract: CourseDetailVO)
|
||||
@@ -60,14 +60,10 @@ public class CourseDetailVO implements Serializable
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date updateTime;
|
||||
|
||||
private String resourceId;
|
||||
|
||||
/**
|
||||
* 已关联的教学资源列表
|
||||
* 注:当前 ISSUE-01-03 未实现真实 Course<->Resource 查询;
|
||||
* 绑定/解绑及真实关联能力属于 P1。为保证集合字段结构稳定,默认返回空列表 (empty list)。
|
||||
*/
|
||||
private List<Object> resources;
|
||||
private List<TrainResource> resources;
|
||||
|
||||
public CourseDetailVO()
|
||||
{
|
||||
@@ -93,9 +89,6 @@ public class CourseDetailVO implements Serializable
|
||||
vo.setCreateTime(course.getCreateTime());
|
||||
vo.setUpdateBy(course.getUpdateBy());
|
||||
vo.setUpdateTime(course.getUpdateTime());
|
||||
vo.setResourceId(course.getResourceId());
|
||||
// 资源绑定/解绑能力属于 P1,当前返回空列表保持响应结构稳定
|
||||
vo.setResources(Collections.emptyList());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -289,20 +282,12 @@ public class CourseDetailVO implements Serializable
|
||||
this.updateTime = updateTime;
|
||||
}
|
||||
|
||||
public String getResourceId() {
|
||||
return resourceId;
|
||||
}
|
||||
|
||||
public void setResourceId(String resourceId) {
|
||||
this.resourceId = resourceId;
|
||||
}
|
||||
|
||||
public List<Object> getResources()
|
||||
public List<TrainResource> getResources()
|
||||
{
|
||||
return resources;
|
||||
}
|
||||
|
||||
public void setResources(List<Object> resources)
|
||||
public void setResources(List<TrainResource> resources)
|
||||
{
|
||||
this.resources = resources;
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.ruoyi.train.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import com.ruoyi.train.domain.TrainResource;
|
||||
|
||||
/**
|
||||
* 课程与教学资源关联Mapper。
|
||||
*/
|
||||
public interface TrainCourseResourceMapper
|
||||
{
|
||||
public List<TrainResource> selectResourcesByCourseId(@Param("cId") String cId);
|
||||
|
||||
public int countBinding(@Param("cId") String cId, @Param("resourceId") String resourceId);
|
||||
|
||||
public int insertBinding(@Param("cId") String cId, @Param("resourceId") String resourceId,
|
||||
@Param("sortNo") Integer sortNo, @Param("createBy") String createBy);
|
||||
|
||||
public int updateBindingSort(@Param("cId") String cId, @Param("resourceId") String resourceId,
|
||||
@Param("sortNo") Integer sortNo);
|
||||
|
||||
public int deleteBinding(@Param("cId") String cId, @Param("resourceId") String resourceId);
|
||||
|
||||
public int countCourseReferencesByResourceId(@Param("resourceId") String resourceId);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.ruoyi.train.service;
|
||||
|
||||
/**
|
||||
* 课程与教学资源关联Service。
|
||||
*/
|
||||
public interface ITrainCourseResourceService
|
||||
{
|
||||
public int bindResource(String cId, String resourceId, Integer sortNo);
|
||||
|
||||
public int unbindResource(String cId, String resourceId);
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.train.domain.TrainResource;
|
||||
import com.ruoyi.train.mapper.TrainCourseMapper;
|
||||
import com.ruoyi.train.mapper.TrainCourseResourceMapper;
|
||||
import com.ruoyi.train.mapper.TrainResourceMapper;
|
||||
import com.ruoyi.train.service.ITrainCourseResourceService;
|
||||
|
||||
/**
|
||||
* 课程与教学资源关联Service实现。
|
||||
*/
|
||||
@Service
|
||||
public class TrainCourseResourceServiceImpl implements ITrainCourseResourceService
|
||||
{
|
||||
@Autowired
|
||||
private TrainCourseMapper trainCourseMapper;
|
||||
|
||||
@Autowired
|
||||
private TrainResourceMapper trainResourceMapper;
|
||||
|
||||
@Autowired
|
||||
private TrainCourseResourceMapper trainCourseResourceMapper;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int bindResource(String cId, String resourceId, Integer sortNo)
|
||||
{
|
||||
if (StringUtils.isEmpty(cId) || trainCourseMapper.selectTrainCourseByCId(cId) == null)
|
||||
{
|
||||
throw new ServiceException("课程不存在", 404);
|
||||
}
|
||||
TrainResource resource = trainResourceMapper.selectTrainResourceByResourceId(resourceId);
|
||||
if (resource == null)
|
||||
{
|
||||
throw new ServiceException("教学资源不存在", 404);
|
||||
}
|
||||
|
||||
int normalizedSortNo = sortNo == null ? 0 : sortNo;
|
||||
if (trainCourseResourceMapper.countBinding(cId, resourceId) > 0)
|
||||
{
|
||||
trainCourseResourceMapper.updateBindingSort(cId, resourceId, normalizedSortNo);
|
||||
return 1;
|
||||
}
|
||||
|
||||
String createBy = null;
|
||||
try
|
||||
{
|
||||
createBy = SecurityUtils.getUsername();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// 无登录上下文的测试环境允许为空。
|
||||
}
|
||||
return trainCourseResourceMapper.insertBinding(cId, resourceId, normalizedSortNo, createBy);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int unbindResource(String cId, String resourceId)
|
||||
{
|
||||
if (StringUtils.isEmpty(cId) || trainCourseMapper.selectTrainCourseByCId(cId) == null)
|
||||
{
|
||||
throw new ServiceException("课程不存在", 404);
|
||||
}
|
||||
int rows = trainCourseResourceMapper.deleteBinding(cId, resourceId);
|
||||
if (rows == 0)
|
||||
{
|
||||
throw new ServiceException("课程未关联该教学资源", 404);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -9,6 +9,7 @@ import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.uuid.IdUtils;
|
||||
import com.ruoyi.train.mapper.TrainCourseMapper;
|
||||
import com.ruoyi.train.mapper.TrainCourseResourceMapper;
|
||||
import com.ruoyi.train.mapper.TrainPlanCourseMapper;
|
||||
import com.ruoyi.train.domain.TrainCourse;
|
||||
import com.ruoyi.train.domain.vo.CourseDetailVO;
|
||||
@@ -26,6 +27,9 @@ public class TrainCourseServiceImpl implements ITrainCourseService
|
||||
@Autowired
|
||||
private TrainCourseMapper trainCourseMapper;
|
||||
|
||||
@Autowired
|
||||
private TrainCourseResourceMapper trainCourseResourceMapper;
|
||||
|
||||
@Autowired
|
||||
private TrainPlanCourseMapper trainPlanCourseMapper;
|
||||
|
||||
@@ -39,7 +43,12 @@ public class TrainCourseServiceImpl implements ITrainCourseService
|
||||
public CourseDetailVO selectCourseDetailByCId(String cId)
|
||||
{
|
||||
TrainCourse course = trainCourseMapper.selectTrainCourseByCId(cId);
|
||||
return CourseDetailVO.from(course);
|
||||
CourseDetailVO detail = CourseDetailVO.from(course);
|
||||
if (detail != null)
|
||||
{
|
||||
detail.setResources(trainCourseResourceMapper.selectResourcesByCourseId(cId));
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+17
@@ -9,6 +9,7 @@ import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.uuid.IdUtils;
|
||||
import com.ruoyi.train.domain.TrainResource;
|
||||
import com.ruoyi.train.mapper.TrainResourceMapper;
|
||||
import com.ruoyi.train.mapper.TrainCourseResourceMapper;
|
||||
import com.ruoyi.train.service.ITrainResourceService;
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,9 @@ public class TrainResourceServiceImpl implements ITrainResourceService
|
||||
@Autowired
|
||||
private TrainResourceMapper trainResourceMapper;
|
||||
|
||||
@Autowired
|
||||
private TrainCourseResourceMapper trainCourseResourceMapper;
|
||||
|
||||
/**
|
||||
* 查询教学资源
|
||||
*
|
||||
@@ -112,6 +116,10 @@ public class TrainResourceServiceImpl implements ITrainResourceService
|
||||
{
|
||||
throw new ServiceException("请选择要删除的资源");
|
||||
}
|
||||
for (String resourceId : resourceIds)
|
||||
{
|
||||
assertResourceNotReferenced(resourceId);
|
||||
}
|
||||
return trainResourceMapper.deleteTrainResourceByResourceIds(resourceIds);
|
||||
}
|
||||
|
||||
@@ -128,9 +136,18 @@ public class TrainResourceServiceImpl implements ITrainResourceService
|
||||
{
|
||||
throw new ServiceException("资源ID不能为空");
|
||||
}
|
||||
assertResourceNotReferenced(resourceId);
|
||||
return trainResourceMapper.deleteTrainResourceByResourceId(resourceId);
|
||||
}
|
||||
|
||||
private void assertResourceNotReferenced(String resourceId)
|
||||
{
|
||||
if (trainCourseResourceMapper.countCourseReferencesByResourceId(resourceId) > 0)
|
||||
{
|
||||
throw new ServiceException("教学资源已被课程引用,请先解除课程关联");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验资源类型取值:1文档 2视频 33D模型 4VR资源 5外部教学链接 6其他
|
||||
*/
|
||||
|
||||
@@ -12,7 +12,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<result property="cDuration" column="c_duration" />
|
||||
<result property="teachingObjective" column="teaching_objective" />
|
||||
<result property="courseContent" column="course_content" />
|
||||
<result property="resourceId" column="resource_id" />
|
||||
<result property="status" column="status" />
|
||||
<result property="delFlag" column="del_flag" />
|
||||
<result property="createBy" column="create_by" />
|
||||
@@ -23,7 +22,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectTrainCourseVo">
|
||||
select c_id, c_code, c_name, c_type, c_duration, teaching_objective, course_content, resource_id, status, del_flag, create_by, create_time, update_by, update_time, remark from train_course
|
||||
select c_id, c_code, c_name, c_type, c_duration, teaching_objective, course_content, status, del_flag, create_by, create_time, update_by, update_time, remark from train_course
|
||||
</sql>
|
||||
|
||||
<select id="selectTrainCourseList" parameterType="TrainCourse" resultMap="TrainCourseResult">
|
||||
@@ -64,7 +63,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<if test="cDuration != null">c_duration,</if>
|
||||
<if test="teachingObjective != null and teachingObjective != ''">teaching_objective,</if>
|
||||
<if test="courseContent != null and courseContent != ''">course_content,</if>
|
||||
<if test="resourceId != null and resourceId != ''">resource_id,</if>
|
||||
<if test="status != null">status,</if>
|
||||
<if test="delFlag != null and delFlag != ''">del_flag,</if>
|
||||
<if test="createBy != null and createBy != ''">create_by,</if>
|
||||
@@ -81,7 +79,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<if test="cDuration != null">#{cDuration},</if>
|
||||
<if test="teachingObjective != null and teachingObjective != ''">#{teachingObjective},</if>
|
||||
<if test="courseContent != null and courseContent != ''">#{courseContent},</if>
|
||||
<if test="resourceId != null and resourceId != ''">#{resourceId},</if>
|
||||
<if test="status != null">#{status},</if>
|
||||
<if test="delFlag != null and delFlag != ''">#{delFlag},</if>
|
||||
<if test="createBy != null and createBy != ''">#{createBy},</if>
|
||||
@@ -101,7 +98,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<if test="cDuration != null">c_duration = #{cDuration},</if>
|
||||
<if test="teachingObjective != null">teaching_objective = #{teachingObjective},</if>
|
||||
<if test="courseContent != null">course_content = #{courseContent},</if>
|
||||
<if test="resourceId != null">resource_id = #{resourceId},</if>
|
||||
<if test="status != null">status = #{status},</if>
|
||||
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
|
||||
<if test="updateTime != null">update_time = #{updateTime},</if>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.train.mapper.TrainCourseResourceMapper">
|
||||
|
||||
<resultMap type="TrainResource" id="CourseResourceResult">
|
||||
<result property="resourceId" column="resource_id" />
|
||||
<result property="resourceName" column="resource_name" />
|
||||
<result property="resourceType" column="resource_type" />
|
||||
<result property="resourceUrl" column="resource_url" />
|
||||
<result property="description" column="description" />
|
||||
<result property="status" column="status" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<select id="selectResourcesByCourseId" parameterType="String" resultMap="CourseResourceResult">
|
||||
select r.resource_id, r.resource_name, r.resource_type, r.resource_url,
|
||||
r.description, r.status, r.create_by, r.create_time,
|
||||
r.update_by, r.update_time, r.remark
|
||||
from train_course_resource cr
|
||||
join train_resource r on r.resource_id = cr.resource_id and r.del_flag = '0'
|
||||
where cr.c_id = #{cId}
|
||||
order by cr.sort_no asc, cr.id asc
|
||||
</select>
|
||||
|
||||
<select id="countBinding" resultType="int">
|
||||
select count(1)
|
||||
from train_course_resource
|
||||
where c_id = #{cId} and resource_id = #{resourceId}
|
||||
</select>
|
||||
|
||||
<insert id="insertBinding">
|
||||
insert into train_course_resource (c_id, resource_id, sort_no, create_by, create_time)
|
||||
values (#{cId}, #{resourceId}, #{sortNo}, #{createBy}, current_timestamp)
|
||||
</insert>
|
||||
|
||||
<update id="updateBindingSort">
|
||||
update train_course_resource
|
||||
set sort_no = #{sortNo}
|
||||
where c_id = #{cId} and resource_id = #{resourceId}
|
||||
</update>
|
||||
|
||||
<delete id="deleteBinding">
|
||||
delete from train_course_resource
|
||||
where c_id = #{cId} and resource_id = #{resourceId}
|
||||
</delete>
|
||||
|
||||
<select id="countCourseReferencesByResourceId" parameterType="String" resultType="int">
|
||||
select count(1)
|
||||
from train_course_resource
|
||||
where resource_id = #{resourceId}
|
||||
</select>
|
||||
</mapper>
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainCourse;
|
||||
import com.ruoyi.train.domain.TrainResource;
|
||||
import com.ruoyi.train.mapper.TrainCourseMapper;
|
||||
import com.ruoyi.train.mapper.TrainCourseResourceMapper;
|
||||
import com.ruoyi.train.mapper.TrainResourceMapper;
|
||||
|
||||
/**
|
||||
* 课程与教学资源规范关联服务测试。
|
||||
*/
|
||||
public class TrainCourseResourceServiceTest
|
||||
{
|
||||
private TrainCourseResourceServiceImpl service;
|
||||
private TrainCourse course;
|
||||
private TrainResource resource;
|
||||
|
||||
@Before
|
||||
public void setUp()
|
||||
{
|
||||
service = new TrainCourseResourceServiceImpl();
|
||||
course = new TrainCourse();
|
||||
course.setcId("C001");
|
||||
resource = new TrainResource();
|
||||
resource.setResourceId("R001");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bindCreatesAssociationInsteadOfSerializingIds() throws Exception
|
||||
{
|
||||
AtomicInteger insertedSortNo = new AtomicInteger(-1);
|
||||
inject("trainCourseMapper", courseMapper(course));
|
||||
inject("trainResourceMapper", resourceMapper(resource));
|
||||
inject("trainCourseResourceMapper", associationMapper((proxy, method, args) -> {
|
||||
if ("countBinding".equals(method.getName()))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if ("insertBinding".equals(method.getName()))
|
||||
{
|
||||
insertedSortNo.set((Integer) args[2]);
|
||||
return 1;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}));
|
||||
|
||||
assertEquals(1, service.bindResource("C001", "R001", 3));
|
||||
assertEquals(3, insertedSortNo.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void duplicateBindUpdatesSortAndRemainsIdempotent() throws Exception
|
||||
{
|
||||
AtomicInteger updates = new AtomicInteger();
|
||||
inject("trainCourseMapper", courseMapper(course));
|
||||
inject("trainResourceMapper", resourceMapper(resource));
|
||||
inject("trainCourseResourceMapper", associationMapper((proxy, method, args) -> {
|
||||
if ("countBinding".equals(method.getName()))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
if ("updateBindingSort".equals(method.getName()))
|
||||
{
|
||||
updates.incrementAndGet();
|
||||
return 1;
|
||||
}
|
||||
if ("insertBinding".equals(method.getName()))
|
||||
{
|
||||
fail("重复绑定不应再次插入关联记录");
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}));
|
||||
|
||||
assertEquals(1, service.bindResource("C001", "R001", 5));
|
||||
assertEquals(1, updates.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bindRejectsMissingResource() throws Exception
|
||||
{
|
||||
inject("trainCourseMapper", courseMapper(course));
|
||||
inject("trainResourceMapper", resourceMapper(null));
|
||||
inject("trainCourseResourceMapper", associationMapper((proxy, method, args) -> defaultValue(method.getReturnType())));
|
||||
|
||||
try
|
||||
{
|
||||
service.bindResource("C001", "R404", 0);
|
||||
fail("不存在的教学资源必须被拒绝");
|
||||
}
|
||||
catch (ServiceException expected)
|
||||
{
|
||||
assertEquals("教学资源不存在", expected.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private TrainCourseMapper courseMapper(TrainCourse value)
|
||||
{
|
||||
return (TrainCourseMapper) Proxy.newProxyInstance(
|
||||
TrainCourseMapper.class.getClassLoader(),
|
||||
new Class<?>[] { TrainCourseMapper.class },
|
||||
(proxy, method, args) -> "selectTrainCourseByCId".equals(method.getName())
|
||||
? value : defaultValue(method.getReturnType()));
|
||||
}
|
||||
|
||||
private TrainResourceMapper resourceMapper(TrainResource value)
|
||||
{
|
||||
return (TrainResourceMapper) Proxy.newProxyInstance(
|
||||
TrainResourceMapper.class.getClassLoader(),
|
||||
new Class<?>[] { TrainResourceMapper.class },
|
||||
(proxy, method, args) -> "selectTrainResourceByResourceId".equals(method.getName())
|
||||
? value : defaultValue(method.getReturnType()));
|
||||
}
|
||||
|
||||
private TrainCourseResourceMapper associationMapper(java.lang.reflect.InvocationHandler handler)
|
||||
{
|
||||
return (TrainCourseResourceMapper) Proxy.newProxyInstance(
|
||||
TrainCourseResourceMapper.class.getClassLoader(),
|
||||
new Class<?>[] { TrainCourseResourceMapper.class }, handler);
|
||||
}
|
||||
|
||||
private void inject(String fieldName, Object value) throws Exception
|
||||
{
|
||||
Field field = TrainCourseResourceServiceImpl.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(service, value);
|
||||
}
|
||||
|
||||
private Object defaultValue(Class<?> type)
|
||||
{
|
||||
if (type == int.class)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (type == boolean.class)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
-- 适用数据库: KingbaseES V009R003C018 (schema: public)
|
||||
-- 执行目标:
|
||||
-- 1. 补齐学员、教师、教室、实训场地、课程主数据新增扩展字段 (11个物理列)
|
||||
-- 2. 创建统一教学资源表 (train_resource) 及其索引与字典
|
||||
-- 2. 创建统一教学资源表及课程-资源规范关联表
|
||||
-- 3. 补齐教学资源管理菜单 (sys_menu) 与培训管理员/超级管理员角色授权 (sys_role_menu)
|
||||
-- 事务边界: 全程在显式事务 BEGIN ... COMMIT 中执行
|
||||
-- 幂等保证: 全部 DDL / DML 均具备 IF NOT EXISTS 或 NOT EXISTS 守卫,可安全重复执行
|
||||
@@ -45,10 +45,6 @@ ALTER TABLE public.train_training_site ADD COLUMN IF NOT EXISTS seat_arrange VAR
|
||||
COMMENT ON COLUMN public.train_training_site.major IS '适用专业/实训工种';
|
||||
COMMENT ON COLUMN public.train_training_site.seat_arrange IS '工位布局形式';
|
||||
|
||||
-- 1.5 课程表 train_course 增加 资源ID关联串 (逗号分隔资源ID)
|
||||
ALTER TABLE public.train_course ADD COLUMN IF NOT EXISTS resource_id VARCHAR(500);
|
||||
COMMENT ON COLUMN public.train_course.resource_id IS '关联教学资源ID集合 (逗号分隔)';
|
||||
|
||||
-- =============================================================================
|
||||
-- 步骤 2: 创建统一教学资源表 (train_resource)
|
||||
-- =============================================================================
|
||||
@@ -81,6 +77,9 @@ COMMENT ON COLUMN public.train_resource.del_flag IS '删除标志 (0:存在, 2:
|
||||
CREATE INDEX IF NOT EXISTS idx_train_resource_type ON public.train_resource (resource_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_train_resource_status ON public.train_resource (status);
|
||||
|
||||
-- 步骤 2 结束: 教学资源表创建完成。课程资源多对多关联见增量迁移脚本 012。
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 步骤 3: 补齐教学资源类型字典 (train_resource_type)
|
||||
-- =============================================================================
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
-- ==============================================================================
|
||||
-- RailwaySystem 数据库 V2 增量迁移脚本: 012_pilot_20261010_course_resource_relation.sql
|
||||
-- 适用数据库: KingbaseES V009R003C018 (schema: public)
|
||||
-- 执行目标: 创建课程与教学资源规范多对多关系表 (train_course_resource),迁移历史关联,保留物理字段兼容
|
||||
-- 架构方案: 改进 B+ 方案 (唯一权威存储为 train_course_resource;保留 train_course.resource_id 禁止 DROP COLUMN)
|
||||
-- ==============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 步骤 1: 创建规范课程资源多对多关联表
|
||||
CREATE TABLE IF NOT EXISTS public.train_course_resource (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
c_id VARCHAR(32) NOT NULL,
|
||||
resource_id VARCHAR(32) NOT NULL,
|
||||
sort_no INTEGER DEFAULT 0,
|
||||
create_by VARCHAR(64),
|
||||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_train_course_resource PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.train_course_resource IS '课程与教学资源关联表 (N:M 唯一权威存储)';
|
||||
COMMENT ON COLUMN public.train_course_resource.id IS '主键ID';
|
||||
COMMENT ON COLUMN public.train_course_resource.c_id IS '课程ID';
|
||||
COMMENT ON COLUMN public.train_course_resource.resource_id IS '教学资源ID';
|
||||
COMMENT ON COLUMN public.train_course_resource.sort_no IS '课程内资源排序号';
|
||||
COMMENT ON COLUMN public.train_course_resource.create_by IS '绑定操作人';
|
||||
COMMENT ON COLUMN public.train_course_resource.create_time IS '绑定时间';
|
||||
|
||||
-- 步骤 2: 创建索引与唯一约束 (保证幂等防重复)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_course_resource
|
||||
ON public.train_course_resource (c_id, resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_course_resource_course
|
||||
ON public.train_course_resource (c_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_course_resource_resource
|
||||
ON public.train_course_resource (resource_id);
|
||||
|
||||
-- 外键约束 (如果不存在则添加)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cr_course') THEN
|
||||
ALTER TABLE public.train_course_resource
|
||||
ADD CONSTRAINT fk_cr_course FOREIGN KEY (c_id)
|
||||
REFERENCES public.train_course (c_id);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cr_resource') THEN
|
||||
ALTER TABLE public.train_course_resource
|
||||
ADD CONSTRAINT fk_cr_resource FOREIGN KEY (resource_id)
|
||||
REFERENCES public.train_resource (resource_id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 步骤 3: 幂等迁移历史 train_course.resource_id 字段数据
|
||||
-- 仅当 train_course.resource_id 非空且关联在 train_resource 中有效(del_flag='0')时迁移,
|
||||
-- 且 ON CONFLICT (c_id, resource_id) DO NOTHING 避免覆盖已存在的关联,亦不破坏已解绑关系。
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'train_course'
|
||||
AND column_name = 'resource_id'
|
||||
) THEN
|
||||
INSERT INTO public.train_course_resource
|
||||
(c_id, resource_id, sort_no, create_by, create_time)
|
||||
SELECT legacy.c_id,
|
||||
legacy.resource_id,
|
||||
legacy.sort_no,
|
||||
'migration_012',
|
||||
CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT c.c_id,
|
||||
trim(parts.resource_id) AS resource_id,
|
||||
CAST(parts.ordinality - 1 AS INTEGER) AS sort_no
|
||||
FROM public.train_course c
|
||||
CROSS JOIN LATERAL regexp_split_to_table(c.resource_id, ',')
|
||||
WITH ORDINALITY AS parts(resource_id, ordinality)
|
||||
WHERE c.resource_id IS NOT NULL
|
||||
AND trim(c.resource_id) <> ''
|
||||
) legacy
|
||||
JOIN public.train_resource r
|
||||
ON r.resource_id = legacy.resource_id
|
||||
AND r.del_flag = '0'
|
||||
WHERE legacy.resource_id <> ''
|
||||
ON CONFLICT (c_id, resource_id) DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 注意:根据改进 B+ 方案决策,train_course.resource_id 物理字段必须永久保留(供向下兼容只读/兼容调用),
|
||||
-- 禁止执行 ALTER TABLE train_course DROP COLUMN resource_id!
|
||||
|
||||
COMMIT;
|
||||
+2
-4
@@ -14,8 +14,8 @@ DELETE FROM public.sys_menu WHERE menu_id IN (2270, 2271, 2272);
|
||||
DELETE FROM public.sys_dict_data WHERE dict_type = 'train_resource_type';
|
||||
DELETE FROM public.sys_dict_type WHERE dict_type = 'train_resource_type';
|
||||
|
||||
-- 3. 删除教学资源表及索引
|
||||
DROP TABLE IF EXISTS public.train_resource CASCADE;
|
||||
-- 3. 删除教学资源表及索引 (无 CASCADE 保护数据安全;train_course_resource 回滚见 012 回滚脚本)
|
||||
DROP TABLE IF EXISTS public.train_resource;
|
||||
|
||||
-- 4. 删除主数据扩展列
|
||||
ALTER TABLE public.train_student DROP COLUMN IF EXISTS avatar;
|
||||
@@ -33,6 +33,4 @@ ALTER TABLE public.train_classroom_info DROP COLUMN IF EXISTS seat_arrange;
|
||||
ALTER TABLE public.train_training_site DROP COLUMN IF EXISTS major;
|
||||
ALTER TABLE public.train_training_site DROP COLUMN IF EXISTS seat_arrange;
|
||||
|
||||
ALTER TABLE public.train_course DROP COLUMN IF EXISTS resource_id;
|
||||
|
||||
COMMIT;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
-- ==============================================================================
|
||||
-- RailwaySystem 数据库 V2 回滚脚本: 012_pilot_20261010_course_resource_relation_rollback.sql
|
||||
-- 适用数据库: KingbaseES V009R003C018 (schema: public)
|
||||
-- 执行目标: 安全回滚 012_pilot_20261010_course_resource_relation.sql 的变更
|
||||
-- ==============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 移除外键约束与关联表(禁止 CASCADE)
|
||||
ALTER TABLE IF EXISTS public.train_course_resource DROP CONSTRAINT IF EXISTS fk_cr_course;
|
||||
ALTER TABLE IF EXISTS public.train_course_resource DROP CONSTRAINT IF EXISTS fk_cr_resource;
|
||||
DROP TABLE IF EXISTS public.train_course_resource;
|
||||
|
||||
COMMIT;
|
||||
@@ -35,6 +35,23 @@ export function updateCourse(data) {
|
||||
})
|
||||
}
|
||||
|
||||
// 绑定课程教学资源
|
||||
export function bindCourseResource(cId, data) {
|
||||
return request({
|
||||
url: '/train/course/' + cId + '/resources',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 解绑课程教学资源(不删除资源本身)
|
||||
export function unbindCourseResource(cId, resourceId) {
|
||||
return request({
|
||||
url: '/train/course/' + cId + '/resources/' + resourceId,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 删除课程信息
|
||||
export function delCourse(cId) {
|
||||
return request({
|
||||
|
||||
@@ -2,34 +2,14 @@
|
||||
<div class="app-container">
|
||||
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="68px">
|
||||
<el-form-item label="课程编码" prop="cCode">
|
||||
<el-input
|
||||
v-model="queryParams.cCode"
|
||||
placeholder="请输入课程编码"
|
||||
clearable
|
||||
@keyup.enter.native="handleQuery"
|
||||
/>
|
||||
<el-input v-model="queryParams.cCode" placeholder="请输入课程编码" clearable @keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="课程名称" prop="cName">
|
||||
<el-input
|
||||
v-model="queryParams.cName"
|
||||
placeholder="请输入课程名称"
|
||||
clearable
|
||||
@keyup.enter.native="handleQuery"
|
||||
/>
|
||||
<el-input v-model="queryParams.cName" placeholder="请输入课程名称" clearable @keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="课程类型" prop="cType">
|
||||
<el-select
|
||||
v-model="queryParams.cType"
|
||||
placeholder="请选择课程类型"
|
||||
clearable
|
||||
@keyup.enter.native="handleQuery"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in dict.type.train_course_type"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
<el-select v-model="queryParams.cType" placeholder="请选择课程类型" clearable>
|
||||
<el-option v-for="dict in dict.type.train_course_type" :key="dict.value" :label="dict.label" :value="dict.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
@@ -46,25 +26,10 @@
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
v-hasPermi="['train:course:add']"
|
||||
type="primary"
|
||||
plain
|
||||
icon="el-icon-plus"
|
||||
size="mini"
|
||||
@click="handleAdd"
|
||||
>新增</el-button>
|
||||
<el-button v-hasPermi="['train:course:add']" type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
v-hasPermi="['train:course:edit']"
|
||||
type="success"
|
||||
plain
|
||||
icon="el-icon-edit"
|
||||
size="mini"
|
||||
:disabled="single"
|
||||
@click="handleUpdate"
|
||||
>修改</el-button>
|
||||
<el-button v-hasPermi="['train:course:edit']" type="success" plain icon="el-icon-edit" size="mini" :disabled="single" @click="handleUpdate">修改</el-button>
|
||||
</el-col>
|
||||
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
|
||||
</el-row>
|
||||
@@ -79,26 +44,14 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="学时" align="center" prop="cDuration" width="80" />
|
||||
<!-- <el-table-column label="教学目标" align="center" prop="teachingObjective" min-width="150" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="课程内容" align="center" prop="courseContent" min-width="160" :show-overflow-tooltip="true" />-->
|
||||
<el-table-column label="状态" align="center" prop="status" width="90">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'">
|
||||
{{ scope.row.status === 1 ? '正常' : '停用' }}
|
||||
</el-tag>
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'">{{ scope.row.status === 1 ? '正常' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column label="备注" align="center" prop="remark" min-width="120" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="创建时间" align="center" prop="createTime" width="160" />-->
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
v-hasPermi="['train:course:edit']"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-edit"
|
||||
@click="handleUpdate(scope.row)"
|
||||
>修改</el-button>
|
||||
<el-button v-hasPermi="['train:course:edit']" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)">修改</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.status === 1"
|
||||
v-hasPermi="['train:course:edit']"
|
||||
@@ -129,119 +82,207 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total>0"
|
||||
:total="total"
|
||||
:page.sync="queryParams.pageNum"
|
||||
:limit.sync="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
<pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
|
||||
|
||||
<!-- 添加或修改课程信息对话框 -->
|
||||
<el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="课程编码" prop="cCode">
|
||||
<el-input v-model="form.cCode" placeholder="请输入课程统一业务编码 (如 KC-2026-001)" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="课程名称" prop="cName">
|
||||
<el-input v-model="form.cName" placeholder="请输入课程名称" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="课程类型" prop="cType">
|
||||
<el-select v-model="form.cType" placeholder="请选择课程类型" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="dict in dict.type.train_course_type"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="课程学时" prop="cDuration">
|
||||
<el-input-number v-model="form.cDuration" :min="1" :max="9999" placeholder="请输入课程学时" style="width: 100%;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="教学目标" prop="teachingObjective">
|
||||
<el-input v-model="form.teachingObjective" type="textarea" :rows="3" placeholder="请输入教学目标" />
|
||||
</el-form-item>
|
||||
<el-form-item label="课程内容" prop="courseContent">
|
||||
<el-input v-model="form.courseContent" type="textarea" :rows="4" placeholder="请输入课程内容/摘要" />
|
||||
</el-form-item>
|
||||
<el-form-item label="课程资源" prop="resourceId">
|
||||
<el-select
|
||||
v-model="resourceIdList"
|
||||
multiple
|
||||
filterable
|
||||
collapse-tags
|
||||
placeholder="请选择课程资源 (可多选)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in resourceOptions"
|
||||
:key="item.resourceId"
|
||||
:label="item.resourceName"
|
||||
:value="String(item.resourceId)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :label="1">正常</el-radio>
|
||||
<el-radio :label="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注" maxlength="500" />
|
||||
</el-form-item>
|
||||
<el-dialog :title="title" :visible.sync="open" width="820px" append-to-body @closed="reset">
|
||||
<el-steps :active="activeStep" finish-status="success" simple class="course-steps">
|
||||
<el-step title="课程基本信息" icon="el-icon-edit-outline" />
|
||||
<el-step title="教学资源" icon="el-icon-folder-opened" />
|
||||
</el-steps>
|
||||
|
||||
<el-form v-show="activeStep === 0" ref="form" :model="form" :rules="rules" label-width="90px" class="course-form">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="课程编码" prop="cCode">
|
||||
<el-input v-model="form.cCode" placeholder="如 KC-2026-001" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="课程名称" prop="cName">
|
||||
<el-input v-model="form.cName" placeholder="请输入课程名称" maxlength="100" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="课程类型" prop="cType">
|
||||
<el-select v-model="form.cType" placeholder="请选择课程类型" clearable style="width: 100%">
|
||||
<el-option v-for="dict in dict.type.train_course_type" :key="dict.value" :label="dict.label" :value="dict.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="课程学时" prop="cDuration">
|
||||
<el-input-number v-model="form.cDuration" :min="1" :max="9999" style="width: 100%;" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="教学目标" prop="teachingObjective">
|
||||
<el-input v-model="form.teachingObjective" type="textarea" :rows="3" placeholder="请输入教学目标" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="课程内容" prop="courseContent">
|
||||
<el-input v-model="form.courseContent" type="textarea" :rows="4" placeholder="请输入课程内容/摘要" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :label="1">正常</el-radio>
|
||||
<el-radio :label="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<div v-show="activeStep === 1" class="resource-step">
|
||||
<el-alert title="课程基本信息已保存,可关联资源库中的资料,或上传新资源后自动关联。" type="success" :closable="false" show-icon />
|
||||
|
||||
<el-card shadow="never" class="resource-card">
|
||||
<div slot="header" class="card-header">
|
||||
<span>已关联资源</span>
|
||||
<el-tag size="small">{{ boundResources.length }} 项</el-tag>
|
||||
</div>
|
||||
<el-table :data="boundResources" empty-text="暂未关联教学资源" size="small">
|
||||
<el-table-column label="资源名称" prop="resourceName" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="类型" prop="resourceType" width="110" align="center">
|
||||
<template slot-scope="scope">
|
||||
<dict-tag :options="dict.type.train_resource_type" :value="scope.row.resourceType" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="文件/地址" prop="resourceUrl" min-width="220" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<el-link :href="resourceLink(scope.row)" target="_blank" type="primary" :underline="false">{{ resourceLabel(scope.row) }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button type="text" size="mini" @click="handleUnbind(scope.row)">解除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="resource-card">
|
||||
<div slot="header">从资源库关联</div>
|
||||
<div class="resource-bind-row">
|
||||
<el-select v-model="resourceToBind" filterable clearable placeholder="搜索并选择已有教学资源" style="flex: 1">
|
||||
<el-option
|
||||
v-for="item in availableResourceOptions"
|
||||
:key="item.resourceId"
|
||||
:label="item.resourceName"
|
||||
:value="item.resourceId"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" :disabled="!resourceToBind" :loading="resourceActionLoading" @click="handleBindExisting">关联资源</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-hasPermi="['train:resource:edit']" shadow="never" class="resource-card">
|
||||
<div slot="header">上传并关联新资源</div>
|
||||
<el-form ref="resourceForm" :model="resourceForm" :rules="resourceRules" label-width="90px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="资源名称" prop="resourceName">
|
||||
<el-input v-model="resourceForm.resourceName" maxlength="150" placeholder="请输入资源名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="资源类型" prop="resourceType">
|
||||
<el-select v-model="resourceForm.resourceType" placeholder="请选择资源类型" style="width: 100%">
|
||||
<el-option v-for="item in resourceTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item v-if="isExternalLink" label="外部链接" prop="resourceUrl">
|
||||
<el-input v-model="resourceForm.resourceUrl" maxlength="500" placeholder="请输入以 http(s):// 开头的地址" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="资源文件" prop="resourceUrl">
|
||||
<file-upload v-model="resourceForm.resourceUrl" :limit="1" :file-size="100" :file-type="fileTypes" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="资源描述" prop="description">
|
||||
<el-input v-model="resourceForm.description" type="textarea" :rows="2" maxlength="500" placeholder="请输入资源描述/使用说明" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<div class="resource-submit">
|
||||
<el-button type="primary" icon="el-icon-upload" :loading="resourceActionLoading" @click="handleCreateAndBind">保存资源并关联</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
<template v-if="activeStep === 0">
|
||||
<el-button type="primary" :loading="savingCourse" @click="saveCourseAndNext">保存并下一步</el-button>
|
||||
<el-button @click="cancel">取消</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button @click="activeStep = 0">上一步</el-button>
|
||||
<el-button type="primary" @click="finishWizard">完成</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listCourse, getCourse, addCourse, updateCourse, delCourse } from '@/api/train/course'
|
||||
import { listResource, getResource } from '@/api/train/resource'
|
||||
import {
|
||||
listCourse,
|
||||
getCourse,
|
||||
addCourse,
|
||||
updateCourse,
|
||||
delCourse,
|
||||
bindCourseResource,
|
||||
unbindCourseResource
|
||||
} from '@/api/train/course'
|
||||
import { listResource, addResource } from '@/api/train/resource'
|
||||
|
||||
export default {
|
||||
name: 'Course',
|
||||
dicts: ['train_course_type'],
|
||||
dicts: ['train_course_type', 'train_resource_type'],
|
||||
data() {
|
||||
const validateDuration = (rule, value, callback) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
const num = Number(value)
|
||||
if (!Number.isInteger(num) || num <= 0) {
|
||||
callback(new Error('课程学时必须为正整数'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
const num = Number(value)
|
||||
if (value !== undefined && value !== null && value !== '' && (!Number.isInteger(num) || num <= 0)) {
|
||||
callback(new Error('课程学时必须为正整数'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
const validateResourceUrl = (rule, value, callback) => {
|
||||
if (!value) {
|
||||
callback(new Error('资源文件或外部链接不能为空'))
|
||||
} else if (Number(this.resourceForm.resourceType) === 5 && !/^https?:\/\//i.test(value)) {
|
||||
callback(new Error('外部链接必须以 http:// 或 https:// 开头'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
return {
|
||||
// 遮罩层
|
||||
loading: true,
|
||||
// 选中数组
|
||||
ids: [],
|
||||
// 非单个禁用
|
||||
single: true,
|
||||
// 显示搜索条件
|
||||
showSearch: true,
|
||||
// 总条数
|
||||
total: 0,
|
||||
// 课程信息表格数据
|
||||
courseList: [],
|
||||
// 弹出层标题
|
||||
title: '',
|
||||
// 是否显示弹出层
|
||||
open: false,
|
||||
// 课程资源下拉备选项
|
||||
activeStep: 0,
|
||||
savingCourse: false,
|
||||
resourceActionLoading: false,
|
||||
resourceOptions: [],
|
||||
// 已选课程资源ID集合 (对应 form.resourceId 逗号分隔字符串)
|
||||
resourceIdList: [],
|
||||
// 查询参数
|
||||
boundResources: [],
|
||||
resourceToBind: null,
|
||||
fileTypes: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf', 'txt', 'zip', 'rar', '7z', 'mp4', 'avi', 'mov', 'wmv', 'mp3', 'png', 'jpg', 'jpeg', 'gif', 'glb', 'gltf', 'obj', 'fbx'],
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
@@ -250,9 +291,8 @@ export default {
|
||||
cType: null,
|
||||
status: null
|
||||
},
|
||||
// 表单参数
|
||||
form: {},
|
||||
// 表单校验
|
||||
resourceForm: {},
|
||||
rules: {
|
||||
cCode: [
|
||||
{ required: true, message: '课程编码不能为空', trigger: 'blur' },
|
||||
@@ -262,71 +302,76 @@ export default {
|
||||
{ required: true, message: '课程名称不能为空', trigger: 'blur' },
|
||||
{ max: 100, message: '课程名称长度不能超过100个字符', trigger: 'blur' }
|
||||
],
|
||||
cDuration: [
|
||||
{ validator: validateDuration, trigger: 'blur' }
|
||||
]
|
||||
cDuration: [{ validator: validateDuration, trigger: 'blur' }]
|
||||
},
|
||||
resourceRules: {
|
||||
resourceName: [{ required: true, message: '资源名称不能为空', trigger: 'blur' }],
|
||||
resourceType: [{ required: true, message: '资源类型不能为空', trigger: 'change' }],
|
||||
resourceUrl: [{ validator: validateResourceUrl, trigger: ['blur', 'change'] }]
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
resourceTypeOptions() {
|
||||
return (this.dict.type.train_resource_type || []).map(item => ({
|
||||
label: item.label,
|
||||
value: /^\d+$/.test(String(item.value)) ? Number(item.value) : item.value
|
||||
}))
|
||||
},
|
||||
isExternalLink() {
|
||||
return Number(this.resourceForm.resourceType) === 5
|
||||
},
|
||||
availableResourceOptions() {
|
||||
const boundIds = this.boundResources.map(item => String(item.resourceId))
|
||||
return this.resourceOptions.filter(item => boundIds.indexOf(String(item.resourceId)) === -1)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'resourceForm.resourceType'(newVal, oldVal) {
|
||||
if (oldVal !== undefined && newVal !== oldVal && this.open) {
|
||||
this.$set(this.resourceForm, 'resourceUrl', null)
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.resourceForm) {
|
||||
this.$refs.resourceForm.clearValidate('resourceUrl')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
this.getResourceOptions()
|
||||
},
|
||||
methods: {
|
||||
/** 查询课程资源下拉备选项 (不做状态过滤,保证已选的停用资源也能正常回显) */
|
||||
getResourceOptions() {
|
||||
return listResource({ pageNum: 1, pageSize: 1000 }).then(response => {
|
||||
this.resourceOptions = response.rows || []
|
||||
return this.resourceOptions
|
||||
}).catch(() => {
|
||||
this.resourceOptions = this.resourceOptions || []
|
||||
return this.resourceOptions
|
||||
})
|
||||
},
|
||||
/** 课程资源ID字符串 -> 多选数组 (统一转成字符串,避免类型不一致导致无法勾选) */
|
||||
parseResourceIds(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return []
|
||||
}
|
||||
return String(value).split(',').map(item => String(item).trim()).filter(item => item !== '')
|
||||
},
|
||||
/** 保证已选资源在下拉选项中存在;element-ui 多选值若无对应 option 会不显示,故必须补齐后再回显 */
|
||||
ensureResourceOptions(ids) {
|
||||
const existingIds = this.resourceOptions.map(item => String(item.resourceId).trim())
|
||||
const missingIds = ids.filter(id => existingIds.indexOf(id) === -1)
|
||||
if (missingIds.length === 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.all(missingIds.map(id => {
|
||||
return getResource(id).then(response => {
|
||||
if (response.data) {
|
||||
this.resourceOptions.push(response.data)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}))
|
||||
},
|
||||
/** 备选项就绪后再回显已选资源 */
|
||||
prepareResourceOptions(ids) {
|
||||
const load = this.resourceOptions.length > 0 ? Promise.resolve() : this.getResourceOptions()
|
||||
return Promise.resolve(load).then(() => this.ensureResourceOptions(ids))
|
||||
},
|
||||
/** 查询课程信息列表 */
|
||||
getList() {
|
||||
this.loading = true
|
||||
listCourse(this.queryParams).then(response => {
|
||||
this.courseList = response.rows
|
||||
//console.log(response.rows);
|
||||
this.total = response.total
|
||||
}).finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
// 取消按钮
|
||||
getResourceOptions() {
|
||||
return listResource({ pageNum: 1, pageSize: 1000 }).then(response => {
|
||||
this.resourceOptions = response.rows || []
|
||||
})
|
||||
},
|
||||
loadCourseResources() {
|
||||
if (!this.form.cId) {
|
||||
this.boundResources = []
|
||||
return Promise.resolve()
|
||||
}
|
||||
return getCourse(this.form.cId).then(response => {
|
||||
this.boundResources = response.data.resources || []
|
||||
})
|
||||
},
|
||||
cancel() {
|
||||
this.open = false
|
||||
this.reset()
|
||||
},
|
||||
// 表单重置
|
||||
reset() {
|
||||
this.activeStep = 0
|
||||
this.savingCourse = false
|
||||
this.resourceActionLoading = false
|
||||
this.form = {
|
||||
cId: null,
|
||||
cCode: null,
|
||||
@@ -335,81 +380,125 @@ export default {
|
||||
cDuration: null,
|
||||
teachingObjective: null,
|
||||
courseContent: null,
|
||||
resourceId: null,
|
||||
status: 1,
|
||||
remark: null
|
||||
}
|
||||
this.resourceIdList = []
|
||||
this.resetResourceForm()
|
||||
this.resourceOptions = []
|
||||
this.boundResources = []
|
||||
this.resourceToBind = null
|
||||
this.resetForm('form')
|
||||
},
|
||||
/** 搜索按钮操作 */
|
||||
resetResourceForm() {
|
||||
this.resourceForm = {
|
||||
resourceId: null,
|
||||
resourceName: null,
|
||||
resourceType: 1,
|
||||
resourceUrl: null,
|
||||
description: null,
|
||||
status: 1,
|
||||
remark: null
|
||||
}
|
||||
this.resetForm('resourceForm')
|
||||
},
|
||||
handleQuery() {
|
||||
this.queryParams.pageNum = 1
|
||||
this.getList()
|
||||
},
|
||||
/** 重置按钮操作 */
|
||||
resetQuery() {
|
||||
this.resetForm('queryForm')
|
||||
this.handleQuery()
|
||||
},
|
||||
// 多选框选中数据
|
||||
handleSelectionChange(selection) {
|
||||
this.ids = selection.map(item => item.cId)
|
||||
this.single = selection.length !== 1
|
||||
},
|
||||
/** 新增按钮操作 */
|
||||
handleAdd() {
|
||||
this.reset()
|
||||
this.prepareResourceOptions([]).then(() => {
|
||||
this.open = true
|
||||
this.title = '添加课程'
|
||||
})
|
||||
this.title = '添加课程'
|
||||
this.open = true
|
||||
},
|
||||
/** 修改按钮操作 */
|
||||
handleUpdate(row) {
|
||||
this.reset()
|
||||
const cId = row.cId || this.ids
|
||||
const cId = (row && row.cId) || this.ids[0]
|
||||
getCourse(cId).then(response => {
|
||||
//console.log(response.data);
|
||||
this.form = response.data
|
||||
if (this.form.status === undefined || this.form.status === null) {
|
||||
this.form.status = 1
|
||||
}
|
||||
const selectedIds = this.parseResourceIds(this.form.resourceId)
|
||||
// 必须先保证这些ID在下拉备选项中存在,否则多选不会被勾选/显示
|
||||
return this.prepareResourceOptions(selectedIds).then(() => {
|
||||
//this.resourceIdList = selectedIds
|
||||
//console.log("selectedIds===============");
|
||||
//console.log(selectedIds);
|
||||
this.$set(this,"resourceIdList",selectedIds);
|
||||
this.open = true
|
||||
this.title = '修改课程'
|
||||
})
|
||||
}).catch(() => {})
|
||||
},
|
||||
/** 提交按钮 */
|
||||
submitForm() {
|
||||
// 多选资源ID以逗号分隔写入 resource_id 字段
|
||||
this.form.resourceId = this.resourceIdList.length > 0 ? this.resourceIdList.join(',') : null
|
||||
this.$refs['form'].validate(valid => {
|
||||
if (valid) {
|
||||
if (this.form.cId != null) {
|
||||
updateCourse(this.form).then(response => {
|
||||
this.$modal.msgSuccess('修改成功')
|
||||
this.open = false
|
||||
this.getList()
|
||||
})
|
||||
} else {
|
||||
addCourse(this.form).then(response => {
|
||||
this.$modal.msgSuccess('新增成功')
|
||||
this.open = false
|
||||
this.getList()
|
||||
})
|
||||
}
|
||||
}
|
||||
this.form = { ...response.data }
|
||||
this.form.status = this.form.status == null ? 1 : Number(this.form.status)
|
||||
this.boundResources = response.data.resources || []
|
||||
this.title = '修改课程'
|
||||
this.open = true
|
||||
})
|
||||
},
|
||||
/** 状态切换(停用/启用) */
|
||||
saveCourseAndNext() {
|
||||
this.$refs.form.validate(valid => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
this.savingCourse = true
|
||||
const saving = this.form.cId ? updateCourse(this.form) : addCourse(this.form)
|
||||
saving.then(response => {
|
||||
if (!this.form.cId) {
|
||||
this.form = { ...this.form, ...response.data }
|
||||
}
|
||||
return Promise.all([this.getResourceOptions(), this.loadCourseResources()])
|
||||
}).then(() => {
|
||||
this.activeStep = 1
|
||||
this.$modal.msgSuccess('课程基本信息已保存')
|
||||
this.getList()
|
||||
}).finally(() => {
|
||||
this.savingCourse = false
|
||||
})
|
||||
})
|
||||
},
|
||||
handleBindExisting() {
|
||||
if (!this.resourceToBind) {
|
||||
return
|
||||
}
|
||||
this.resourceActionLoading = true
|
||||
bindCourseResource(this.form.cId, {
|
||||
resourceId: this.resourceToBind,
|
||||
sortNo: this.boundResources.length
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('资源关联成功')
|
||||
this.resourceToBind = null
|
||||
return this.loadCourseResources()
|
||||
}).finally(() => {
|
||||
this.resourceActionLoading = false
|
||||
})
|
||||
},
|
||||
handleCreateAndBind() {
|
||||
this.$refs.resourceForm.validate(valid => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
this.resourceActionLoading = true
|
||||
addResource(this.resourceForm).then(response => {
|
||||
const resource = response.data
|
||||
return bindCourseResource(this.form.cId, {
|
||||
resourceId: resource.resourceId,
|
||||
sortNo: this.boundResources.length
|
||||
})
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('资源已上传并关联')
|
||||
this.resetResourceForm()
|
||||
return Promise.all([this.getResourceOptions(), this.loadCourseResources()])
|
||||
}).finally(() => {
|
||||
this.resourceActionLoading = false
|
||||
})
|
||||
})
|
||||
},
|
||||
handleUnbind(resource) {
|
||||
this.$modal.confirm(`确定解除资源《${resource.resourceName}》与当前课程的关联吗?资源本身不会被删除。`).then(() => {
|
||||
return unbindCourseResource(this.form.cId, resource.resourceId)
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('已解除关联')
|
||||
return this.loadCourseResources()
|
||||
}).catch(() => {})
|
||||
},
|
||||
finishWizard() {
|
||||
this.open = false
|
||||
this.getList()
|
||||
},
|
||||
handleStatusChange(row, newStatus) {
|
||||
const actionText = newStatus === 1 ? '启用' : '停用'
|
||||
const promptText = newStatus === 0
|
||||
@@ -422,7 +511,6 @@ export default {
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
},
|
||||
/** 删除按钮操作 */
|
||||
handleDelete(row) {
|
||||
this.$modal.confirm(`确定删除课程《${row.cName}》吗?`).then(() => {
|
||||
return delCourse(row.cId)
|
||||
@@ -430,7 +518,60 @@ export default {
|
||||
this.$modal.msgSuccess('删除成功')
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
},
|
||||
resourceLink(resource) {
|
||||
if (!resource || !resource.resourceUrl) {
|
||||
return ''
|
||||
}
|
||||
if (/^https?:\/\//i.test(resource.resourceUrl)) {
|
||||
return resource.resourceUrl
|
||||
}
|
||||
return process.env.VUE_APP_BASE_API + resource.resourceUrl
|
||||
},
|
||||
resourceLabel(resource) {
|
||||
if (!resource || !resource.resourceUrl) {
|
||||
return ''
|
||||
}
|
||||
if (Number(resource.resourceType) === 5) {
|
||||
return resource.resourceUrl
|
||||
}
|
||||
return resource.resourceUrl.substring(resource.resourceUrl.lastIndexOf('/') + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.course-steps {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.course-form {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.resource-step {
|
||||
max-height: 62vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.resource-bind-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.resource-submit {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
# FEAT-20261009 课程两步式向导与教学资源关联 开发与验收文档
|
||||
|
||||
> **功能编号:** `FEAT-20261009-COURSE-WIZARD`
|
||||
> **关联模块:** `ISSUE-01-03 课程库基础资料管理`
|
||||
> **开发分支:** `feat/20261009-course-wizard`
|
||||
> **开发基线:** `55143af4a518222007d9b82dfbe0677a04f06150`
|
||||
> **API Contract:** `docs/api-v2/02_module_a_base_resource_api.md`
|
||||
> **数据库:** KingbaseES / Database V2
|
||||
> **实施日期:** 2026-10-09
|
||||
> **当前状态:** 代码开发完成,静态构建与自动化测试通过;待提交、推送、CI 与运行时验收
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能目标与范围
|
||||
|
||||
本功能将课程维护改造为两步式向导,并补齐课程与教学资源之间的真实关联能力:
|
||||
|
||||
1. 第一步维护课程编码、名称、类型、学时、教学目标、课程内容、状态与备注;
|
||||
2. 第二步查看已关联资源、关联资源库中的既有资源、上传新资源并自动关联、解除资源关联;
|
||||
3. 课程与资源使用规范化多对多关联表 `train_course_resource`,禁止在 `train_course` 中保存逗号分隔资源 ID;
|
||||
4. 复用既有课程、教学资源与通用文件上传能力,不引入新的文件存储协议;
|
||||
5. 不修改培训计划课程配置、排课及其他计划流程模块。
|
||||
|
||||
### 1.1 明确边界
|
||||
|
||||
- 解除关联只删除 `train_course_resource` 关系,不删除 `train_resource` 资源记录及实际文件;
|
||||
- 被课程引用的资源禁止直接删除,必须先解除课程关联;
|
||||
- 本功能不提供资源文件物理删除、资源版本管理、断点续传及大文件分片上传;
|
||||
- 本功能未执行真实生产数据库迁移或写入。
|
||||
|
||||
---
|
||||
|
||||
## 2. 用户操作流程
|
||||
|
||||
### 2.1 新增课程
|
||||
|
||||
1. 用户点击“新增”;
|
||||
2. 在“课程基本信息”步骤填写并保存课程;
|
||||
3. 后端返回已生成 `cId`,前端进入“教学资源”步骤;
|
||||
4. 用户可关联既有资源,或通过通用上传组件上传文件、创建资源并自动绑定;
|
||||
5. 用户点击“完成”关闭向导并刷新课程列表。
|
||||
|
||||
### 2.2 修改课程
|
||||
|
||||
1. 用户从列表进入“修改”;
|
||||
2. 页面加载课程详情及其真实资源列表;
|
||||
3. 用户可修改课程基本信息,也可进入第二步新增或解除资源关联;
|
||||
4. 解除关联不会删除资源库中的资源。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型与设计决策
|
||||
|
||||
### 3.1 规范化关联模型
|
||||
|
||||
课程与资源采用标准 N:M 模型:
|
||||
|
||||
```text
|
||||
train_course (1) ──< train_course_resource >── (1) train_resource
|
||||
```
|
||||
|
||||
`train_course_resource` 核心字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | `BIGINT IDENTITY` | 关联记录主键 |
|
||||
| `c_id` | `VARCHAR(32)` | 课程 ID,外键指向 `train_course.c_id` |
|
||||
| `resource_id` | `VARCHAR(32)` | 资源 ID,外键指向 `train_resource.resource_id` |
|
||||
| `sort_no` | `INTEGER` | 课程内资源排序号 |
|
||||
| `create_by` | `VARCHAR(64)` | 创建人 |
|
||||
| `create_time` | `TIMESTAMP` | 创建时间 |
|
||||
|
||||
唯一约束为 `(c_id, resource_id)`,防止同一资源被重复绑定到同一课程。
|
||||
|
||||
### 3.2 旧字段兼容迁移
|
||||
|
||||
迁移脚本兼容曾执行旧版 011 的环境:
|
||||
|
||||
1. 检测 `train_course.resource_id` 是否存在;
|
||||
2. 将逗号分隔的资源 ID 拆分后迁移到 `train_course_resource`;
|
||||
3. 忽略不存在或已逻辑删除的资源;
|
||||
4. 使用唯一约束避免重复迁移;
|
||||
5. 完成迁移后删除 `train_course.resource_id` 非规范字段。
|
||||
|
||||
---
|
||||
|
||||
## 4. API 与权限
|
||||
|
||||
### 4.1 本次新增或调整的课程接口
|
||||
|
||||
| HTTP Method | Path | 权限 | 说明 |
|
||||
|:---:|---|---|---|
|
||||
| `GET` | `/train/course/{cId}` | `train:course:query` | 返回 `CourseDetailVO`,其中 `resources` 为真实关联资源列表 |
|
||||
| `POST` | `/train/course` | `train:course:add` | 新增课程并在 `data` 中返回包含生成后 `cId` 的课程对象 |
|
||||
| `POST` | `/train/course/{cId}/resources` | `train:course:edit` | 绑定资源;请求体为 `resourceId`、`sortNo` |
|
||||
| `DELETE` | `/train/course/{cId}/resources/{resourceId}` | `train:course:edit` | 解除课程资源关联,不删除资源本身 |
|
||||
|
||||
### 4.2 复用的资源接口
|
||||
|
||||
| HTTP Method | Path | 权限 | 用途 |
|
||||
|:---:|---|---|---|
|
||||
| `GET` | `/train/resource/list` | `train:resource:list` | 获取可关联资源 |
|
||||
| `POST` | `/train/resource` | `train:resource:edit` | 创建教学资源并返回生成后的资源对象 |
|
||||
| `POST` | `/common/upload` | 登录用户 | 复用通用上传组件上传资源文件 |
|
||||
|
||||
### 4.3 请求模型
|
||||
|
||||
`CourseResourceBindDTO`:
|
||||
|
||||
| 字段 | 校验 | 说明 |
|
||||
|---|---|---|
|
||||
| `resourceId` | `@NotBlank` | 必填资源 ID |
|
||||
| `sortNo` | `@Min(0)` | 可选排序号,默认 0 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端实现
|
||||
|
||||
| 层级 | 文件 | 实现内容 |
|
||||
|---|---|---|
|
||||
| Controller | `TrainCourseController` | 返回新增课程对象;增加资源绑定、解绑端点 |
|
||||
| Controller | `TrainResourceController` | 新增资源后返回包含 `resourceId` 的资源对象 |
|
||||
| DTO | `CourseResourceBindDTO` | 绑定请求参数与校验 |
|
||||
| VO | `CourseDetailVO` | `resources` 调整为 `List<TrainResource>` |
|
||||
| Service | `TrainCourseResourceServiceImpl` | 校验课程和资源、幂等绑定、更新排序、解除关联 |
|
||||
| Service | `TrainCourseServiceImpl` | 查询课程详情时聚合真实资源列表 |
|
||||
| Service | `TrainResourceServiceImpl` | 删除前检查课程引用并给出友好提示 |
|
||||
| Mapper | `TrainCourseResourceMapper` | 关联查询、新增、更新、删除与引用计数 |
|
||||
| Mapper XML | `TrainCourseResourceMapper.xml` | 实现规范化关联 SQL,并过滤已逻辑删除资源 |
|
||||
| Entity/Mapper | `TrainCourse`、`TrainCourseMapper.xml` | 移除旧 `resourceId/resource_id` 逗号串字段 |
|
||||
|
||||
绑定操作的业务语义为幂等:若关联已存在,则更新 `sort_no`,不重复插入关联记录。
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端实现
|
||||
|
||||
主要页面:`build_ui/src/views/train/course/index.vue`
|
||||
|
||||
实现内容:
|
||||
|
||||
1. 使用 `el-steps` 提供“课程基本信息 → 教学资源”两步流程;
|
||||
2. 新增课程时先保存课程并取得后端生成的 `cId`;
|
||||
3. 展示真实关联资源及其文件/链接;
|
||||
4. 支持搜索并关联资源库中尚未绑定的资源;
|
||||
5. 非外部链接资源复用 `file-upload` 组件;
|
||||
6. 外部链接校验必须以 `http://` 或 `https://` 开头;
|
||||
7. 上传资源后调用既有资源新增接口,再调用课程资源绑定接口;
|
||||
8. 支持解除关联,并明确提示资源本身不会被删除。
|
||||
|
||||
前端 API 文件 `build_ui/src/api/train/course.js` 新增:
|
||||
|
||||
- `bindCourseResource(cId, data)`;
|
||||
- `unbindCourseResource(cId, resourceId)`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据库迁移与回滚
|
||||
|
||||
### 7.1 正向迁移
|
||||
|
||||
脚本:`RuoYi-Vue/sql/train/v2/011_pilot_20261010_master_data_and_resource.sql`
|
||||
|
||||
- 幂等创建 `train_resource` 与 `train_course_resource`;
|
||||
- 创建课程资源唯一索引、课程索引与资源索引;
|
||||
- 创建课程、资源外键;
|
||||
- 迁移旧逗号 ID 后删除 `train_course.resource_id`;
|
||||
- 补齐资源类型字典、菜单与角色授权。
|
||||
|
||||
### 7.2 回滚
|
||||
|
||||
脚本:`RuoYi-Vue/sql/train/v2/rollback/011_pilot_20261010_master_data_and_resource_rollback.sql`
|
||||
|
||||
回滚脚本会删除资源菜单、字典、课程资源关联表与资源表,并回滚 011 中的主数据扩展列。该操作具有破坏性,只允许在确认无正式数据的回滚窗口执行。
|
||||
|
||||
---
|
||||
|
||||
## 8. 自动化验证结果
|
||||
|
||||
| 验证项 | 结果 | 说明 |
|
||||
|---|:---:|---|
|
||||
| `ruoyi-train` 全量测试 | **PASS** | 393 tests,0 failures,0 errors,0 skipped |
|
||||
| 课程资源关联定向测试 | **PASS** | 3 tests,覆盖新增绑定、重复绑定幂等更新、资源不存在拦截 |
|
||||
| 后端业务模块编译 | **PASS** | `ruoyi-train` 编译成功 |
|
||||
| 前端 ESLint | **PASS** | 课程页面与课程 API 文件 0 errors |
|
||||
| 前端生产构建 | **PASS** | `npm run build:prod` 成功 |
|
||||
| 完整 `ruoyi-admin` 本地聚合构建 | **环境阻断** | 本机缺少非公开依赖 `com.kingbase:kingbase8:8.6.0`,不是本次代码编译错误 |
|
||||
| 数据库迁移实跑 | **PENDING** | 未连接目标 Kingbase 环境执行 011 |
|
||||
| 浏览器端到端验收 | **PENDING** | 需在迁移后的集成环境验证上传、绑定、解绑完整流程 |
|
||||
|
||||
前端构建存在一条仓库既有警告:`src/views/graph/antv/index2.vue` 使用的 `@antv/x6` 未导出 `Addon`。该警告与课程模块无关,未阻断构建。
|
||||
|
||||
---
|
||||
|
||||
## 9. Acceptance Criteria
|
||||
|
||||
| AC | 验收内容 | 当前状态 |
|
||||
|:---:|---|:---:|
|
||||
| AC-01 | 新增课程保存后可进入资源步骤,且获得有效 `cId` | **STATIC/AUTOMATED PASS** |
|
||||
| AC-02 | 课程详情返回真实关联资源列表 | **STATIC/AUTOMATED PASS** |
|
||||
| AC-03 | 可关联既有资源,同一资源不会重复绑定 | **AUTOMATED PASS** |
|
||||
| AC-04 | 可上传新资源并自动关联课程 | **BUILD PASS / RUNTIME PENDING** |
|
||||
| AC-05 | 解除关联不删除资源本身 | **STATIC PASS / RUNTIME PENDING** |
|
||||
| AC-06 | 被课程引用的资源禁止删除 | **STATIC PASS / RUNTIME PENDING** |
|
||||
| AC-07 | 数据库不再使用逗号分隔资源 ID | **MIGRATION STATIC PASS / RUNTIME PENDING** |
|
||||
| AC-08 | 不影响培训计划课程及排课流程 | **REGRESSION PASS** |
|
||||
|
||||
---
|
||||
|
||||
## 10. 上线与验证顺序
|
||||
|
||||
1. 在备份后的集成数据库执行正向迁移 011;
|
||||
2. 核对 `train_course_resource` 表、唯一约束、外键、字典和菜单;
|
||||
3. 构建并部署后端、前端;
|
||||
4. 使用具备课程及资源权限的账号进行浏览器验收;
|
||||
5. 验证新增课程、修改课程、上传资源、关联既有资源、解除关联和资源删除保护;
|
||||
6. 核对操作日志与数据库关联记录;
|
||||
7. 验收通过后再合并到目标集成分支。
|
||||
|
||||
---
|
||||
|
||||
## 11. Git 与交付状态
|
||||
|
||||
- 当前代码与本文档均位于本地分支 `feat/20261009-course-wizard`;
|
||||
- 当前改动尚未执行 `git commit`、`git push` 或创建合并请求;
|
||||
- 远端页面显示的 `Status: Failed` 对应基线提交 `55143af4` 的既有 CI Pipeline,不代表本地尚未提交的课程向导代码已经执行或失败;
|
||||
- 推荐先完成本地变更审查并按中文提交规范提交,再推送该功能分支触发新的 CI;
|
||||
- 新 Pipeline 全部通过后,再向 `integration/20261010-pilot` 创建合并请求。
|
||||
|
||||
建议提交信息:
|
||||
|
||||
```text
|
||||
新增培训:实现课程两步式向导与教学资源关联
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 已知事项
|
||||
|
||||
1. 本地完整管理端构建依赖授权的 Kingbase JDBC 驱动;CI Runner 必须先通过 `scripts/bootstrap/check-kingbase-jdbc.sh`;
|
||||
2. 当前远端失败 Pipeline 的具体失败 Job 和日志需在 GitLab Pipeline 页面查看,单凭分支页红色状态无法判断根因;
|
||||
3. 推送不会自动部署生产环境:当前 `.gitlab-ci.yml` 仅允许 `feat/cicd-pipeline` 执行 `deploy_production`,本功能分支只会运行后端、前端构建测试。
|
||||
@@ -1,5 +1,7 @@
|
||||
# ISSUE-01-03 课程库基础资料管理 开发与验收文档 (A- / Final Cleanup 修订版)
|
||||
|
||||
> **后续能力更新(2026-10-09):** 本文记录早期课程库 P0 建设历史。其中“教学资源仅返回空列表、绑定/解绑尚未实现”等描述已被后续功能替代。当前两步式课程向导、资源上传及规范化关联的实现与验收状态,以 [`feat-20261009-course-wizard.md`](./feat-20261009-course-wizard.md) 为准。
|
||||
|
||||
> **Issue ID:** `ISSUE-01-03`
|
||||
> **所属 Epic:** `EPIC-01 基础主数据必要集合`
|
||||
> **分支基线:** `feat/issue-01-03-course` (HEAD: `7fcaea29b8810546fd972ba0c9d366c71a357d63`)
|
||||
|
||||
Reference in new issue
Block a user