新增培训:实现课程两步式向导与教学资源关联

This commit is contained in:
dongjie committed 2026-10-09 16:55:52 +08:00
1 parent 55143af4a5
commit 83d22ba5e0
20 files changed
+1145 -290

No files matched your search

+2
View File
@@ -16,6 +16,8 @@ node_modules/
**/node_modules/
**/node_modules1/
**/node_modules2/
.m2repo/
.npm-cache/
*.class
**/*.class
@@ -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));
}
/**
* 删除课程信息
*/
@@ -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())
@@ -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;
}
@@ -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);
}
@@ -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);
}
@@ -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;
}
}
@@ -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;
}
/**
@@ -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>
@@ -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,81 @@ 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.1 课程与教学资源采用规范 N:M 关联,不在课程表保存逗号分隔 ID
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.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 '课程内资源排序号';
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 $$;
-- 兼容曾执行过旧版 011 的环境:先迁移逗号串,再删除非规范字段。
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
EXECUTE $migration$
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',
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
$migration$;
END IF;
END $$;
ALTER TABLE public.train_course DROP COLUMN IF EXISTS resource_id;
-- =============================================================================
-- 步骤 3: 补齐教学资源类型字典 (train_resource_type)
-- =============================================================================
@@ -14,7 +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. 删除教学资源表及索引
-- 3. 删除课程资源关联表、教学资源表及索引
DROP TABLE IF EXISTS public.train_course_resource CASCADE;
DROP TABLE IF EXISTS public.train_resource CASCADE;
-- 4. 删除主数据扩展列
@@ -33,6 +34,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;
+17
View File
@@ -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({
+380 -239
View File
@@ -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`,本功能分支只会运行后端、前端构建测试。
+2
View File
@@ -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`)