课程资源分上传资源、选取已有资源;课程被班级关联时不可停用(删除)课程;学员导入模板
This commit is contained in:
1 parent
ff129170eb
commit
6d2ad20723
14 files changed
+1098
-219
No files matched your search
@@ -61,6 +61,9 @@ public class TrainResource extends BaseEntity
|
||||
/** 自增编号(数据库递增序号)。 */
|
||||
private Long id;
|
||||
|
||||
/** 课程引用数(派生字段,非数据库列,仅详情查询时回填)。 */
|
||||
private Integer courseReferenceCount;
|
||||
|
||||
public void setResourceId(String resourceId)
|
||||
{
|
||||
this.resourceId = resourceId;
|
||||
@@ -139,6 +142,14 @@ public class TrainResource extends BaseEntity
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Integer getCourseReferenceCount() {
|
||||
return courseReferenceCount;
|
||||
}
|
||||
|
||||
public void setCourseReferenceCount(Integer courseReferenceCount) {
|
||||
this.courseReferenceCount = courseReferenceCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
@@ -151,6 +162,7 @@ public class TrainResource extends BaseEntity
|
||||
.append("status", getStatus())
|
||||
.append("delFlag", getDelFlag())
|
||||
.append("id", getId())
|
||||
.append("courseReferenceCount", getCourseReferenceCount())
|
||||
.append("createBy", getCreateBy())
|
||||
.append("createTime", getCreateTime())
|
||||
.append("updateBy", getUpdateBy())
|
||||
|
||||
@@ -71,6 +71,9 @@ public class CourseDetailVO implements Serializable
|
||||
*/
|
||||
private List<TrainResource> resources;
|
||||
|
||||
/** 未完成状态开班计划的引用数(派生字段,供课程停用校验使用)。 */
|
||||
private Integer unfinishedPlanRefCount;
|
||||
|
||||
public CourseDetailVO()
|
||||
{
|
||||
}
|
||||
@@ -317,4 +320,14 @@ public class CourseDetailVO implements Serializable
|
||||
{
|
||||
this.resources = resources;
|
||||
}
|
||||
|
||||
public Integer getUnfinishedPlanRefCount()
|
||||
{
|
||||
return unfinishedPlanRefCount;
|
||||
}
|
||||
|
||||
public void setUnfinishedPlanRefCount(Integer unfinishedPlanRefCount)
|
||||
{
|
||||
this.unfinishedPlanRefCount = unfinishedPlanRefCount;
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,16 @@ public interface TrainPlanCourseMapper
|
||||
*/
|
||||
int countPlanCoursesByCId(@Param("cId") String cId);
|
||||
|
||||
/**
|
||||
* 统计指定公共课程被「未完成」状态开班计划引用的数量。
|
||||
* 未完成 = plan_status in (0 未开始, 1 进行中) 且计划未删除;
|
||||
* 已结束(2)、已取消(3)及已移除的本期课程配置不计入。
|
||||
*
|
||||
* @param cId 公共课程ID
|
||||
* @return 未完成计划引用数
|
||||
*/
|
||||
int countUnfinishedPlanCoursesByCId(@Param("cId") String cId);
|
||||
|
||||
/**
|
||||
* 统计指定公共课程关联的当前有效V2成绩明细数(train_student_score + train_score_item 下游引用)。
|
||||
*
|
||||
|
||||
+33
@@ -49,6 +49,8 @@ public class TrainCourseServiceImpl implements ITrainCourseService
|
||||
{
|
||||
List<TrainResource> resources = trainCourseResourceMapper.selectResourcesByCourseId(cId);
|
||||
detail.setResources(resources);
|
||||
// 派生字段:供编辑窗口与表格停用开关判断,不参与新增/修改入库
|
||||
detail.setUnfinishedPlanRefCount(trainPlanCourseMapper.countUnfinishedPlanCoursesByCId(cId));
|
||||
// 规范权威读取:优先以 train_course_resource 规范关联表首个资源派生兼容 resourceId;
|
||||
// 若关系表暂无关联但实体存在历史 resourceId,则保留历史 resourceId
|
||||
if (resources != null && !resources.isEmpty())
|
||||
@@ -181,6 +183,9 @@ public class TrainCourseServiceImpl implements ITrainCourseService
|
||||
throw new ServiceException("课程学时必须为正整数", 400);
|
||||
}
|
||||
|
||||
// 4. 由正常改为停用时,校验未完成开班计划的引用
|
||||
assertUnfinishedPlanCourseNotDisabled(trainCourse);
|
||||
|
||||
trainCourse.setUpdateTime(DateUtils.getNowDate());
|
||||
try
|
||||
{
|
||||
@@ -193,6 +198,34 @@ public class TrainCourseServiceImpl implements ITrainCourseService
|
||||
return trainCourseMapper.updateTrainCourse(trainCourse);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已被「未完成」状态开班计划引用的课程禁止由「正常」改为「停用」。
|
||||
*
|
||||
* <p>触发条件:本次提交状态为停用(0)、库中原有状态为正常(1),且该课程仍被
|
||||
* 未开始(0)/进行中(1)且未删除的开班计划引用(train_plan_course)。
|
||||
* 计划已结束(2)、已取消(3)、或本期课程配置已被移除时不再拦截。
|
||||
* 作用范围:仅 PUT /train/course/{cId} 的状态变更;新增、状态未变、停用改正常、
|
||||
* 原本已停用、以及只修改其它字段均不受影响。
|
||||
* 预期行为:抛出 ServiceException,不执行更新,前端提示先等待班级完成或取消课程配置。</p>
|
||||
*/
|
||||
private void assertUnfinishedPlanCourseNotDisabled(TrainCourse trainCourse)
|
||||
{
|
||||
Integer newStatus = trainCourse.getStatus();
|
||||
if (newStatus == null || newStatus != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
TrainCourse existing = trainCourseMapper.selectTrainCourseByCId(trainCourse.getcId());
|
||||
if (existing == null || existing.getStatus() == null || existing.getStatus() != 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (trainPlanCourseMapper.countUnfinishedPlanCoursesByCId(trainCourse.getcId()) > 0)
|
||||
{
|
||||
throw new ServiceException("课程已被未完成状态的班级引用,不可停用;请等待班级完成、取消计划或移除本期课程配置后再试");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验课程统一业务编码是否唯一
|
||||
*
|
||||
|
||||
+35
-1
@@ -38,7 +38,13 @@ public class TrainResourceServiceImpl implements ITrainResourceService
|
||||
@Override
|
||||
public TrainResource selectTrainResourceByResourceId(String resourceId)
|
||||
{
|
||||
return trainResourceMapper.selectTrainResourceByResourceId(resourceId);
|
||||
TrainResource resource = trainResourceMapper.selectTrainResourceByResourceId(resourceId);
|
||||
if (resource != null)
|
||||
{
|
||||
// 派生字段:供编辑窗口判断该资源是否允许停用,不参与新增/修改入库
|
||||
resource.setCourseReferenceCount(trainCourseResourceMapper.countCourseReferencesByResourceId(resourceId));
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +105,7 @@ public class TrainResourceServiceImpl implements ITrainResourceService
|
||||
{
|
||||
validateStatus(trainResource.getStatus());
|
||||
}
|
||||
assertBoundResourceNotDisabled(trainResource);
|
||||
trainResource.setUpdateTime(DateUtils.getNowDate());
|
||||
return trainResourceMapper.updateTrainResource(trainResource);
|
||||
}
|
||||
@@ -148,6 +155,33 @@ public class TrainResourceServiceImpl implements ITrainResourceService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 已绑定课程的教学资源禁止由「正常」改为「停用」。
|
||||
*
|
||||
* <p>触发条件:本次提交状态为停用(0)、库中原有状态为正常(1),且 train_course_resource
|
||||
* 中仍存在该资源的关联记录,三个条件同时满足才拦截。
|
||||
* 作用范围:仅 PUT /train/resource/{resourceId} 修改;新增资源、状态未变更、停用改正常、
|
||||
* 原本已停用、以及未被课程引用的资源均不受影响。
|
||||
* 预期行为:抛出 ServiceException,不执行更新,前端提示先解除课程关联。</p>
|
||||
*/
|
||||
private void assertBoundResourceNotDisabled(TrainResource trainResource)
|
||||
{
|
||||
Integer newStatus = trainResource.getStatus();
|
||||
if (newStatus == null || newStatus != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
TrainResource existing = trainResourceMapper.selectTrainResourceByResourceId(trainResource.getResourceId());
|
||||
if (existing == null || existing.getStatus() == null || existing.getStatus() != 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (trainCourseResourceMapper.countCourseReferencesByResourceId(trainResource.getResourceId()) > 0)
|
||||
{
|
||||
throw new ServiceException("教学资源已被课程引用,不可停用,请先解除课程关联");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验资源类型取值:1文档 2视频 33D模型 4VR资源 5外部教学链接 6其他
|
||||
*/
|
||||
|
||||
@@ -122,6 +122,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
select count(*) from train_plan_course where c_id = #{cId}
|
||||
</select>
|
||||
|
||||
<select id="countUnfinishedPlanCoursesByCId" parameterType="String" resultType="int">
|
||||
select count(1)
|
||||
from train_plan_course pc
|
||||
join train_class_plan p on p.plan_id = pc.plan_id
|
||||
where pc.c_id = #{cId}
|
||||
and p.del_flag = '0'
|
||||
and p.plan_status in (0, 1)
|
||||
</select>
|
||||
|
||||
<select id="countStudentScoresByCId" parameterType="String" resultType="int">
|
||||
select count(1)
|
||||
from train_student_score ss
|
||||
|
||||
+5
@@ -1256,6 +1256,11 @@ public class SchedulingAppServiceTest
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countUnfinishedPlanCoursesByCId(String cId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countStudentScoresByCId(String cId)
|
||||
{
|
||||
|
||||
+5
@@ -622,6 +622,11 @@ public class SchedulingTeacherStatusAndOptionsTest
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countUnfinishedPlanCoursesByCId(String cId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countStudentScoresByCId(String cId)
|
||||
{
|
||||
|
||||
Binary file not shown.
@@ -56,7 +56,7 @@
|
||||
:disabled="isPlanLocked"
|
||||
@click="handleOpenBatchAddModal"
|
||||
>添加参训人员</el-button>
|
||||
<!-- <el-button
|
||||
<el-button
|
||||
v-hasPermi="['train:enrollment:import']"
|
||||
type="success"
|
||||
plain
|
||||
@@ -64,7 +64,7 @@
|
||||
size="mini"
|
||||
:disabled="isPlanLocked"
|
||||
@click="handleOpenBatchModal"
|
||||
>批量导入</el-button>-->
|
||||
>批量导入</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -461,8 +461,8 @@
|
||||
@click="downloadImportTemplate"
|
||||
>下载导入模板</el-button>
|
||||
<div class="el-upload__tip import-template-tip">
|
||||
模板为 .xlsx,含「填写说明」工作表与一行示例数据(示例行导入时自动忽略)。列顺序固定:
|
||||
学员ID(必填,须与学员主档学号一致)、参训来源(1/2/3,留空=2)、住宿需求(0/1,留空=0)、备注(自动建档时作为学员姓名)。
|
||||
模板为 .xlsx,含「学员导入模板」工作表与一行示例数据(导入前请删除示例行)。列顺序固定:
|
||||
学员姓名(必填)、联系电话(必填)、身份证号(必填)、性别、出生日期、所属单位、学号、房间号、门禁卡号、备注。
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="上传文件">
|
||||
@@ -874,6 +874,7 @@ import { addAuth } from '@/api/iot/auth'
|
||||
import { listDevice } from '@/api/iot/device'
|
||||
import { listFace, extract } from '@/api/iot/face'
|
||||
import { getPlan } from '@/api/train/plan'
|
||||
import { saveAs } from 'file-saver'
|
||||
|
||||
/**
|
||||
* 门禁授权结果图标规则:字典值(train_accommodation_lock_result) → 图标 + 颜色。
|
||||
@@ -1565,9 +1566,25 @@ export default {
|
||||
this.batchAddDialog.studentCandidates = []
|
||||
},
|
||||
// ---- Excel 批量导入 ----
|
||||
/** 下载批量导入模板:xlsx,含逐列填写规则说明与一行示例数据 */
|
||||
/** 下载批量导入模板:直接输出静态 xlsx 模板文件,内容与模板完全一致 */
|
||||
downloadImportTemplate() {
|
||||
this.download('train/enrollments/importTemplate', {}, `参训人员导入模板_${new Date().getTime()}.xlsx`)
|
||||
const url = (process.env.BASE_URL || '/') + 'files/template/enrollment_import_template.xlsx'
|
||||
fetch(url).then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`模板下载失败:${response.status}`)
|
||||
}
|
||||
return response.blob()
|
||||
}).then(blob => {
|
||||
if (!blob.size || /text\/html/i.test(blob.type)) {
|
||||
throw new Error('模板文件不可用')
|
||||
}
|
||||
saveAs(
|
||||
new Blob([blob], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
||||
'学员导入模板.xlsx'
|
||||
)
|
||||
}).catch(() => {
|
||||
this.$modal.msgError('导入模板下载失败,请稍后重试')
|
||||
})
|
||||
},
|
||||
handleOpenBatchModal() {
|
||||
this.batchDialog.file = null
|
||||
|
||||
@@ -709,7 +709,8 @@ export default {
|
||||
getCoursePool() {
|
||||
this.selectorDialog.loading = true
|
||||
listCourse(this.selectorDialog.queryParams).then(response => {
|
||||
this.selectorDialog.list = response.rows || []
|
||||
// 引入课程弹窗只展示「正常」状态的课程:请求已带 status=1,此处对结果再过滤一次兜底
|
||||
this.selectorDialog.list = (response.rows || []).filter(item => Number(item.status) === 1)
|
||||
}).finally(() => {
|
||||
this.selectorDialog.loading = false
|
||||
})
|
||||
|
||||
@@ -64,11 +64,12 @@
|
||||
<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" class-name="small-padding fixed-width" width="240">
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="320">
|
||||
<template slot-scope="scope">
|
||||
<el-button v-hasPermi="['train:course:query']" size="mini" type="text" icon="el-icon-view" @click="handleDetail(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
|
||||
<el-button v-hasPermi="['train:course:edit']" size="mini" type="text" icon="el-icon-folder-opened" @click="handleResource(scope.row)">课程资源</el-button>
|
||||
<!--<el-button
|
||||
v-if="scope.row.status === 1"
|
||||
v-hasPermi="['train:course:edit']"
|
||||
size="mini"
|
||||
@@ -85,7 +86,7 @@
|
||||
icon="el-icon-video-play"
|
||||
style="color: #67C23A;"
|
||||
@click="handleStatusChange(scope.row, 1)"
|
||||
>启用</el-button>
|
||||
>启用</el-button>-->
|
||||
<el-button
|
||||
v-hasPermi="['train:course:remove']"
|
||||
size="mini"
|
||||
@@ -100,13 +101,9 @@
|
||||
|
||||
<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="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-form 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">
|
||||
@@ -144,8 +141,11 @@
|
||||
<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 :label="0" :disabled="courseStatusDisableBlocked">停用</el-radio>
|
||||
</el-radio-group>
|
||||
<div v-if="courseStatusDisableBlocked" style="color: #e6a23c; font-size: 12px; line-height: 1.5;">
|
||||
该课程已被 {{ formUnfinishedPlanRefCount }} 个未完成状态的班级引用,不可停用;请等待班级完成(已结束/已取消)或移除本期课程配置后再试。
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
@@ -156,95 +156,172 @@
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<div v-show="activeStep === 1" class="resource-step">
|
||||
<el-alert title="课程基本信息已保存,可关联资源库中的资料,或上传新资源后自动关联。" type="success" :closable="false" show-icon />
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button type="primary" :loading="savingCourse" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-card shadow="never" class="resource-card">
|
||||
<div slot="header" class="card-header">
|
||||
<span>已关联资源</span>
|
||||
<el-tag size="small">{{ boundResources.length }} 项</el-tag>
|
||||
<!-- 课程资源:专门管理本课程关联的教学资源 -->
|
||||
<el-dialog :title="resourceTitle" :visible.sync="resourceOpen" width="960px" top="6vh" append-to-body class="course-resource-dialog">
|
||||
<div v-loading="resourceLoading" class="resource-manage">
|
||||
<!-- 概览 + 添加入口 -->
|
||||
<div class="resource-hero">
|
||||
<div class="resource-hero-info">
|
||||
<div class="resource-hero-title">本课程资源</div>
|
||||
<div class="resource-hero-sub">共 {{ boundResources.length }} 项 · {{ resourceTypeCount }} 个类型 · 当前显示 {{ filteredResources.length }} 项</div>
|
||||
</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-button type="primary" size="small" :icon="addPanelOpen ? 'el-icon-arrow-up' : 'el-icon-plus'" @click="addPanelOpen = !addPanelOpen">
|
||||
{{ addPanelOpen ? '收起添加' : '添加课程资源' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<!-- 主要视觉区域:检索 + 资源列表 -->
|
||||
<section class="resource-main">
|
||||
<div class="resource-toolbar">
|
||||
<el-input v-model="resourceKeyword" size="small" clearable prefix-icon="el-icon-search" placeholder="搜索资源名称 / 文件名" class="resource-search" />
|
||||
<el-select v-model="resourceTypeFilter" size="small" clearable placeholder="全部类型" class="resource-filter">
|
||||
<el-option v-for="item in resourceTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-button type="primary" :disabled="!resourceToBind" :loading="resourceActionLoading" @click="handleBindExisting">关联资源</el-button>
|
||||
<el-select v-model="resourceSort" size="small" class="resource-sort">
|
||||
<el-option label="关联顺序" value="default" />
|
||||
<el-option label="名称排序" value="name" />
|
||||
<el-option label="类型排序" value="type" />
|
||||
</el-select>
|
||||
<el-radio-group v-model="resourceViewMode" size="small" class="resource-view">
|
||||
<el-radio-button label="group">分类</el-radio-button>
|
||||
<el-radio-button label="table">列表</el-radio-button>
|
||||
</el-radio-group>
|
||||
</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 ref="resourceListScroll" class="resource-list-scroll">
|
||||
<template v-if="resourceViewMode === 'group'">
|
||||
<div v-if="groupedResources.length" class="resource-groups">
|
||||
<div v-for="group in groupedResources" :id="'resource-group-' + group.type" :key="group.type" class="resource-group">
|
||||
<div class="resource-group-head">
|
||||
<span class="resource-group-dot" :style="{ background: group.color }" />
|
||||
<span class="resource-group-name">{{ group.label }}</span>
|
||||
<el-tag size="mini" type="info">{{ group.items.length }} 项</el-tag>
|
||||
</div>
|
||||
<div class="resource-entries">
|
||||
<div v-for="item in group.items" :key="item.resourceId" class="resource-entry" @click="handleViewResource(item)">
|
||||
<span class="resource-entry-icon" :style="{ background: group.color + '18', color: group.color }">
|
||||
<i :class="resourceIcon(item)" />
|
||||
</span>
|
||||
<div class="resource-entry-body">
|
||||
<div class="resource-entry-name" :title="item.resourceName">{{ item.resourceName || '未命名资源' }}</div>
|
||||
<div class="resource-entry-meta" :title="item.resourceUrl">{{ resourceLabel(item) || '—' }}</div>
|
||||
</div>
|
||||
<el-button type="text" size="mini" icon="el-icon-delete" class="resource-entry-remove" @click.stop="handleUnbind(item)">解除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="70" description="没有符合条件的资源" />
|
||||
</template>
|
||||
<el-table v-else :data="filteredResources" size="small" empty-text="没有符合条件的资源">
|
||||
<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="200" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<el-link type="primary" :underline="false" @click.native.prevent="handleViewResource(scope.row)">{{ resourceLabel(scope.row) }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button type="text" size="mini" @click="handleViewResource(scope.row)">查看</el-button>
|
||||
<el-button type="text" size="mini" @click="handleUnbind(scope.row)">解除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</section>
|
||||
|
||||
<!-- 次要区域:按需展开的添加课程资源(保留上传 / 关联两个 Tab) -->
|
||||
<el-collapse-transition>
|
||||
<section v-show="addPanelOpen" class="resource-add-section">
|
||||
<div class="resource-section-title">添加课程资源</div>
|
||||
<el-tabs v-model="resourceTab" class="resource-tabs">
|
||||
<el-tab-pane label="上传资源" name="upload">
|
||||
<el-alert :title="uploadTypeTip" type="info" :closable="false" show-icon class="resource-upload-tip" />
|
||||
<div v-hasPermi="['train:resource:edit']">
|
||||
<el-form ref="resourceForm" :model="resourceForm" :rules="resourceRules" label-width="90px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="资源名称" prop="resourceName">
|
||||
<el-input v-model="resourceForm.resourceName" maxlength="150" placeholder="请输入资源名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="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="allowedFileTypes" :is-show-tip="false" />
|
||||
</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>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="关联已有资源" name="bind">
|
||||
<el-alert title="从资源库中勾选已有资料,可一次关联多条到本课程。" type="info" :closable="false" show-icon />
|
||||
<el-table
|
||||
ref="resourceBindTable"
|
||||
:data="availableResourceOptions"
|
||||
size="small"
|
||||
max-height="260"
|
||||
empty-text="暂无可关联的资源"
|
||||
class="resource-bind-table"
|
||||
@selection-change="handleResourceSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="46" align="center" />
|
||||
<el-table-column label="名称" prop="resourceName" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="类型" prop="resourceType" width="100" 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="200" show-overflow-tooltip>
|
||||
<template slot-scope="scope">{{ resourceLabel(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预览" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button type="text" size="mini" @click="handleViewResource(scope.row)">预览</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="resource-bind-row">
|
||||
<span class="resource-bind-tip">已选 {{ selectedResources.length }} 项</span>
|
||||
<el-button type="primary" :disabled="!selectedResources.length" :loading="resourceActionLoading" @click="handleBindExisting">关联所选资源</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</el-collapse-transition>
|
||||
</div>
|
||||
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<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>
|
||||
<el-button @click="closeResource">关 闭</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
@@ -341,9 +418,28 @@ import {
|
||||
} from '@/api/train/course'
|
||||
import { listResource, addResource } from '@/api/train/resource'
|
||||
|
||||
// 资源类型(字典 train_resource_type)与允许上传的文件格式:
|
||||
// 1 文档 / 2 视频 / 3 3D模型 / 4 VR资源 / 5 外部教学链接(无文件上传)/ 6 其他(用兜底全集)
|
||||
const RESOURCE_FILE_TYPES = {
|
||||
1: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf', 'txt'],
|
||||
2: ['mp4', 'avi', 'mov', 'wmv', 'm4v', 'flv', 'webm', 'mkv', 'mpg', 'mpeg'],
|
||||
3: ['glb', 'gltf', 'obj', 'fbx', 'stl'],
|
||||
4: ['zip', 'rar', '7z', 'glb', 'gltf', 'obj', 'fbx', 'mp4'],
|
||||
5: []
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源类型规范化:与上半部分列表的分组口径完全一致,覆盖字典全部取值及空值。
|
||||
* 用于判断新添加的资源类型是否已在列表(含未分类)中存在。
|
||||
*/
|
||||
function resourceTypeKey(resource) {
|
||||
const type = resource && resource.resourceType
|
||||
return type === null || type === undefined || type === '' ? 'other' : String(type)
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Course',
|
||||
dicts: ['train_course_type', 'train_resource_type'],
|
||||
dicts: ['train_course_type', 'train_resource_type', 'train_common_status'],
|
||||
data() {
|
||||
const validateDuration = (rule, value, callback) => {
|
||||
const num = Number(value)
|
||||
@@ -356,11 +452,25 @@ export default {
|
||||
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
|
||||
}
|
||||
const type = Number(this.resourceForm.resourceType)
|
||||
if (type === 5) {
|
||||
if (!/^https?:\/\//i.test(value)) {
|
||||
callback(new Error('外部链接必须以 http:// 或 https:// 开头'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
return
|
||||
}
|
||||
const allowed = RESOURCE_FILE_TYPES[type] || this.fileTypes
|
||||
const path = String(value).split('?')[0].split('#')[0]
|
||||
const ext = path.substring(path.lastIndexOf('.') + 1).toLowerCase()
|
||||
if (allowed.indexOf(ext) === -1) {
|
||||
callback(new Error(`当前资源类型仅支持 ${allowed.join(' / ')} 格式`))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
return {
|
||||
loading: true,
|
||||
@@ -379,12 +489,23 @@ export default {
|
||||
previewType: '',
|
||||
previewUrl: '',
|
||||
previewTitle: '资源预览',
|
||||
activeStep: 0,
|
||||
savingCourse: false,
|
||||
// 课程资源弹窗状态
|
||||
resourceOpen: false,
|
||||
resourceLoading: false,
|
||||
resourceTab: 'upload',
|
||||
resourceCourse: { cId: null, cName: '' },
|
||||
// 资源列表检索与展示
|
||||
addPanelOpen: true,
|
||||
resourceKeyword: '',
|
||||
resourceTypeFilter: null,
|
||||
resourceSort: 'default',
|
||||
resourceViewMode: 'group',
|
||||
resourceActionLoading: false,
|
||||
resourceOptions: [],
|
||||
boundResources: [],
|
||||
resourceToBind: null,
|
||||
// 「关联已有资源」表格的多选结果
|
||||
selectedResources: [],
|
||||
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,
|
||||
@@ -395,6 +516,9 @@ export default {
|
||||
status: null
|
||||
},
|
||||
form: {},
|
||||
// 课程停用校验:编辑窗口打开时的未完成班级引用数与原状态
|
||||
formUnfinishedPlanRefCount: 0,
|
||||
formOriginalStatus: null,
|
||||
resourceForm: {},
|
||||
rules: {
|
||||
cCode: [
|
||||
@@ -415,6 +539,39 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* 课程停用限制:仅修改场景 + 仍被「未完成」状态班级引用 + 打开窗口时为正常状态。
|
||||
* 班级已结束/已取消、课程配置已移除、新增课程及启用操作均不受影响。
|
||||
*/
|
||||
courseStatusDisableBlocked() {
|
||||
return this.form.cId != null &&
|
||||
this.formUnfinishedPlanRefCount > 0 &&
|
||||
Number(this.formOriginalStatus) === 1
|
||||
},
|
||||
// 资源状态取自字典 train_common_status,与 resource/index 口径一致
|
||||
resourceStatusOptions() {
|
||||
return (this.dict.type.train_common_status || []).map(item => ({
|
||||
label: item.label,
|
||||
value: /^-?\d+$/.test(String(item.value)) ? Number(item.value) : item.value,
|
||||
raw: item.raw || {}
|
||||
}))
|
||||
},
|
||||
/** 「正常」状态值:仅该状态的资源可被关联 */
|
||||
normalStatusValue() {
|
||||
const hit = this.resourceStatusOptions.find(item => {
|
||||
const raw = item.raw || {}
|
||||
return raw.isDefault === 'Y' || raw.isDefault === true || raw.listClass === 'primary' || String(item.label).indexOf('正常') !== -1
|
||||
})
|
||||
return hit ? hit.value : null
|
||||
},
|
||||
/** 上传新资源时的默认状态,不写死数值 */
|
||||
defaultResourceStatus() {
|
||||
if (this.normalStatusValue != null) return this.normalStatusValue
|
||||
return this.resourceStatusOptions.length ? this.resourceStatusOptions[0].value : null
|
||||
},
|
||||
resourceTitle() {
|
||||
return this.resourceCourse.cName ? `课程资源 · ${this.resourceCourse.cName}` : '课程资源'
|
||||
},
|
||||
resourceTypeOptions() {
|
||||
return (this.dict.type.train_resource_type || []).map(item => ({
|
||||
label: item.label,
|
||||
@@ -424,9 +581,75 @@ export default {
|
||||
isExternalLink() {
|
||||
return Number(this.resourceForm.resourceType) === 5
|
||||
},
|
||||
/** 按所选资源类型动态限制可上传的文件格式 */
|
||||
allowedFileTypes() {
|
||||
return RESOURCE_FILE_TYPES[Number(this.resourceForm.resourceType)] || this.fileTypes
|
||||
},
|
||||
uploadTypeTip() {
|
||||
if (this.isExternalLink) {
|
||||
return '外部教学链接无需上传文件,请填写以 http(s):// 开头的地址。'
|
||||
}
|
||||
const label = this.dictLabel('train_resource_type', this.resourceForm.resourceType) || '当前资源类型'
|
||||
return `当前资源类型为「${label}」,仅支持 ${this.allowedFileTypes.join(' / ')} 格式,单个文件不超过 100MB;切换资源类型后限制会同步更新。`
|
||||
},
|
||||
/** 资源列表:关键字检索 + 类型筛选 + 排序 */
|
||||
filteredResources() {
|
||||
const keyword = String(this.resourceKeyword || '').trim().toLowerCase()
|
||||
const type = this.resourceTypeFilter
|
||||
const hasType = type !== null && type !== '' && type !== undefined
|
||||
let list = (this.boundResources || []).filter(item => {
|
||||
if (hasType && String(item.resourceType) !== String(type)) return false
|
||||
if (keyword) {
|
||||
const text = (String(item.resourceName || '') + ' ' + String(item.resourceUrl || '')).toLowerCase()
|
||||
if (text.indexOf(keyword) === -1) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (this.resourceSort === 'name') {
|
||||
list = list.slice().sort((a, b) => String(a.resourceName || '').localeCompare(String(b.resourceName || ''), 'zh-Hans-CN'))
|
||||
} else if (this.resourceSort === 'type') {
|
||||
list = list.slice().sort((a, b) => {
|
||||
const diff = (Number(a.resourceType) || 0) - (Number(b.resourceType) || 0)
|
||||
return diff || String(a.resourceName || '').localeCompare(String(b.resourceName || ''), 'zh-Hans-CN')
|
||||
})
|
||||
}
|
||||
return list
|
||||
},
|
||||
/** 按资源类型分组展示,分组顺序与字典编码一致 */
|
||||
groupedResources() {
|
||||
const map = {}
|
||||
this.filteredResources.forEach(item => {
|
||||
const key = resourceTypeKey(item)
|
||||
if (!map[key]) map[key] = []
|
||||
map[key].push(item)
|
||||
})
|
||||
return Object.keys(map).sort((a, b) => {
|
||||
if (a === 'other') return 1
|
||||
if (b === 'other') return -1
|
||||
return (Number(a) || 0) - (Number(b) || 0)
|
||||
}).map(key => ({
|
||||
type: key,
|
||||
label: this.resourceTypeLabel(key),
|
||||
color: this.resourceTypeColor(key),
|
||||
items: map[key]
|
||||
}))
|
||||
},
|
||||
resourceTypeCount() {
|
||||
const types = {}
|
||||
const list = this.boundResources || []
|
||||
list.forEach(item => {
|
||||
types[resourceTypeKey(item)] = true
|
||||
})
|
||||
return Object.keys(types).length
|
||||
},
|
||||
availableResourceOptions() {
|
||||
const boundIds = this.boundResources.map(item => String(item.resourceId))
|
||||
return this.resourceOptions.filter(item => boundIds.indexOf(String(item.resourceId)) === -1)
|
||||
const normal = this.normalStatusValue
|
||||
return this.resourceOptions.filter(item => {
|
||||
if (boundIds.indexOf(String(item.resourceId)) !== -1) return false
|
||||
// 字典异步到达后也会重新计算,保证下拉始终只有正常状态的可关联资源
|
||||
return normal == null || (item.status != null && String(item.status) === String(normal))
|
||||
})
|
||||
},
|
||||
featuredResource() {
|
||||
if (!this.detailResources.length) {
|
||||
@@ -446,7 +669,7 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
'resourceForm.resourceType'(newVal, oldVal) {
|
||||
if (oldVal !== undefined && newVal !== oldVal && this.open) {
|
||||
if (oldVal !== undefined && newVal !== oldVal && this.resourceOpen) {
|
||||
this.$set(this.resourceForm, 'resourceUrl', null)
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.resourceForm) {
|
||||
@@ -474,20 +697,94 @@ export default {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
/** 可关联资源:只保留字典中「正常」状态的资源;字典未就绪时退化为原行为,避免下拉为空 */
|
||||
getResourceOptions() {
|
||||
return listResource({ pageNum: 1, pageSize: 1000 }).then(response => {
|
||||
this.resourceOptions = response.rows || []
|
||||
const rows = response.rows || []
|
||||
const normal = this.normalStatusValue
|
||||
this.resourceOptions = normal == null
|
||||
? rows
|
||||
: rows.filter(item => item.status != null && String(item.status) === String(normal))
|
||||
// 选项刷新后旧勾选可能已失效,统一清空
|
||||
this.clearResourceSelection()
|
||||
})
|
||||
},
|
||||
/** 打开课程资源弹窗:以当前行课程为主体加载资源库与已关联资源 */
|
||||
handleResource(row) {
|
||||
if (!row || !row.cId) return
|
||||
this.resetResourceManage()
|
||||
this.resourceCourse = { cId: row.cId, cName: row.cName || '' }
|
||||
this.resourceOpen = true
|
||||
this.resourceLoading = true
|
||||
Promise.all([this.getResourceOptions(), this.loadCourseResources()])
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this.resourceLoading = false
|
||||
})
|
||||
},
|
||||
/** 关闭课程资源弹窗并同步清空状态,避免异步 closed 事件与再次打开产生竞态 */
|
||||
closeResource() {
|
||||
this.resourceOpen = false
|
||||
this.resetResourceManage()
|
||||
},
|
||||
/** 重置课程资源弹窗状态 */
|
||||
resetResourceManage() {
|
||||
this.resourceCourse = { cId: null, cName: '' }
|
||||
this.resourceLoading = false
|
||||
this.resourceTab = 'upload'
|
||||
this.addPanelOpen = true
|
||||
this.resourceKeyword = ''
|
||||
this.resourceTypeFilter = null
|
||||
this.resourceSort = 'default'
|
||||
this.resourceViewMode = 'group'
|
||||
this.resourceActionLoading = false
|
||||
this.resourceOptions = []
|
||||
this.boundResources = []
|
||||
this.clearResourceSelection()
|
||||
this.resetResourceForm()
|
||||
},
|
||||
loadCourseResources() {
|
||||
if (!this.form.cId) {
|
||||
const cId = this.resourceCourse.cId
|
||||
if (!cId) {
|
||||
this.boundResources = []
|
||||
return Promise.resolve()
|
||||
}
|
||||
return getCourse(this.form.cId).then(response => {
|
||||
return getCourse(cId).then(response => {
|
||||
// 请求期间已切换到别的课程则丢弃过期结果
|
||||
if (this.resourceCourse.cId !== cId) return
|
||||
this.boundResources = response.data.resources || []
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 隐藏的自动刷新:添加资源成功后静默同步上半部分列表,无需用户操作。
|
||||
* 列表中原先没有该资源类型时,额外清掉会遮挡新条目的检索条件;
|
||||
* 所有类型统一以 getCourse 的全量关联结果为准,保证数据准确及时。
|
||||
*/
|
||||
syncAfterResourceAdded(resource) {
|
||||
// 兼容单条上传/关联与批量关联两种场景
|
||||
const added = (Array.isArray(resource) ? resource : [resource]).filter(Boolean)
|
||||
const addedTypes = added.map(item => resourceTypeKey(item))
|
||||
const missingType = addedTypes.some(type => !this.boundResources.some(item => resourceTypeKey(item) === type))
|
||||
if (missingType) {
|
||||
this.resourceKeyword = ''
|
||||
const filter = this.resourceTypeFilter
|
||||
if (filter !== null && filter !== '' && filter !== undefined && addedTypes.indexOf(String(filter)) === -1) {
|
||||
this.resourceTypeFilter = null
|
||||
}
|
||||
}
|
||||
const firstType = addedTypes[0]
|
||||
return Promise.all([this.getResourceOptions(), this.loadCourseResources()]).then(() => {
|
||||
if (!missingType || this.resourceViewMode !== 'group' || !firstType) return
|
||||
// 新类型分组渲染后滚到该分组,无需手动查找即可看到刚添加的资源
|
||||
this.$nextTick(() => {
|
||||
const container = this.$refs.resourceListScroll
|
||||
const target = container && container.querySelector('[id="resource-group-' + firstType + '"]')
|
||||
if (target) {
|
||||
container.scrollTop += target.getBoundingClientRect().top - container.getBoundingClientRect().top
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
cancel() {
|
||||
this.open = false
|
||||
},
|
||||
@@ -547,10 +844,21 @@ export default {
|
||||
const item = list.find(d => String(d.value) === String(value))
|
||||
return item ? item.label : ''
|
||||
},
|
||||
/** 资源分组名称:优先取字典,未配置时给出可读兜底 */
|
||||
resourceTypeLabel(value) {
|
||||
if (value === 'other') return '未分类'
|
||||
return this.dictLabel('train_resource_type', value) || ('类型 ' + value)
|
||||
},
|
||||
resourceTypeColor(value) {
|
||||
const colors = { 1: '#2b6bff', 2: '#10b981', 3: '#722ed1', 4: '#f79009', 5: '#36cfc9', 6: '#8a94a6' }
|
||||
return colors[Number(value)] || '#8a94a6'
|
||||
},
|
||||
resourceIcon(item) {
|
||||
const icons = { 1: 'el-icon-document', 2: 'el-icon-video-camera', 3: 'el-icon-box', 4: 'el-icon-view', 5: 'el-icon-link', 6: 'el-icon-folder' }
|
||||
return icons[Number(item.resourceType)] || 'el-icon-folder'
|
||||
},
|
||||
reset() {
|
||||
this.activeStep = 0
|
||||
this.savingCourse = false
|
||||
this.resourceActionLoading = false
|
||||
this.form = {
|
||||
cId: null,
|
||||
cCode: null,
|
||||
@@ -562,10 +870,8 @@ export default {
|
||||
status: 1,
|
||||
remark: null
|
||||
}
|
||||
this.resetResourceForm()
|
||||
this.resourceOptions = []
|
||||
this.boundResources = []
|
||||
this.resourceToBind = null
|
||||
this.formUnfinishedPlanRefCount = 0
|
||||
this.formOriginalStatus = null
|
||||
this.resetForm('form')
|
||||
},
|
||||
resetResourceForm() {
|
||||
@@ -575,7 +881,7 @@ export default {
|
||||
resourceType: 1,
|
||||
resourceUrl: null,
|
||||
description: null,
|
||||
status: 1,
|
||||
status: this.defaultResourceStatus,
|
||||
remark: null
|
||||
}
|
||||
this.resetForm('resourceForm')
|
||||
@@ -601,47 +907,84 @@ export default {
|
||||
this.reset()
|
||||
const cId = (row && row.cId) || this.ids[0]
|
||||
getCourse(cId).then(response => {
|
||||
this.form = { ...response.data }
|
||||
// 未完成班级引用数是详情接口附带的派生字段,不参与表单提交
|
||||
const { unfinishedPlanRefCount, ...courseForm } = response.data
|
||||
this.form = { ...courseForm }
|
||||
this.form.status = this.form.status == null ? 1 : Number(this.form.status)
|
||||
this.boundResources = response.data.resources || []
|
||||
this.formUnfinishedPlanRefCount = Number(unfinishedPlanRefCount) || 0
|
||||
this.formOriginalStatus = this.form.status
|
||||
this.title = '修改课程'
|
||||
this.open = true
|
||||
})
|
||||
},
|
||||
saveCourseAndNext() {
|
||||
/** 新增/修改课程:只保存基本信息,资源由“课程资源”弹窗单独管理 */
|
||||
submitForm() {
|
||||
this.$refs.form.validate(valid => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
// 前置拦截:即使绕过禁用的单选项,也不提交被未完成班级引用课程的停用变更
|
||||
if (this.courseStatusDisableBlocked && Number(this.form.status) === 0) {
|
||||
this.$modal.msgWarning('该课程已被未完成状态的班级引用,不可停用;请等待班级完成或移除本期课程配置后再试')
|
||||
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('课程基本信息已保存')
|
||||
const isEdit = !!this.form.cId
|
||||
const saving = isEdit ? updateCourse(this.form) : addCourse(this.form)
|
||||
saving.then(() => {
|
||||
this.$modal.msgSuccess(isEdit ? '修改成功' : '新增成功')
|
||||
this.open = false
|
||||
this.getList()
|
||||
}).finally(() => {
|
||||
this.savingCourse = false
|
||||
})
|
||||
})
|
||||
},
|
||||
/** 「关联已有资源」表格多选:全选、单选、多选由 el-table 选择列统一处理 */
|
||||
handleResourceSelectionChange(selection) {
|
||||
this.selectedResources = selection || []
|
||||
},
|
||||
/** 清空勾选:关闭弹窗、选项刷新、关联成功后调用 */
|
||||
clearResourceSelection() {
|
||||
this.selectedResources = []
|
||||
if (this.$refs.resourceBindTable) {
|
||||
this.$refs.resourceBindTable.clearSelection()
|
||||
}
|
||||
},
|
||||
/** 关联已有资源:一次勾选多条,逐条绑定后统一刷新列表 */
|
||||
handleBindExisting() {
|
||||
if (!this.resourceToBind) {
|
||||
const rows = this.selectedResources.slice()
|
||||
if (!rows.length) {
|
||||
this.$modal.msgWarning('请先选择要关联的资源')
|
||||
return
|
||||
}
|
||||
const cId = this.resourceCourse.cId
|
||||
if (!cId) return
|
||||
// 只接受仍在正常状态可关联列表中的资源,防止绕过表格选中非正常状态资源
|
||||
const validIds = this.availableResourceOptions.map(item => String(item.resourceId))
|
||||
const validRows = rows.filter(item => validIds.indexOf(String(item.resourceId)) !== -1)
|
||||
if (!validRows.length) {
|
||||
this.$modal.msgWarning('所选资源不可关联,请重新选择状态正常的资源')
|
||||
this.clearResourceSelection()
|
||||
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(() => {
|
||||
const baseSort = this.boundResources.length
|
||||
const tasks = validRows.map((item, index) => bindCourseResource(cId, {
|
||||
resourceId: item.resourceId,
|
||||
sortNo: baseSort + index
|
||||
}).then(() => true, () => false))
|
||||
// 单条失败不影响其它条目,部分成功也按后端全量结果刷新
|
||||
Promise.all(tasks).then(results => {
|
||||
const count = results.filter(Boolean).length
|
||||
if (!count) {
|
||||
this.$modal.msgError('资源关联失败,请稍后重试')
|
||||
return
|
||||
}
|
||||
this.$modal.msgSuccess(count === validRows.length ? `已关联 ${count} 个资源` : `已关联 ${count} 个资源,${validRows.length - count} 个失败`)
|
||||
this.clearResourceSelection()
|
||||
return this.syncAfterResourceAdded(validRows)
|
||||
}).catch(() => {}).finally(() => {
|
||||
this.resourceActionLoading = false
|
||||
})
|
||||
},
|
||||
@@ -650,17 +993,22 @@ export default {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
// 新增接口可能只返回资源ID,先用表单里实际选择的类型兜底,保证类型判定准确
|
||||
const formType = this.resourceForm.resourceType
|
||||
this.resourceActionLoading = true
|
||||
addResource(this.resourceForm).then(response => {
|
||||
const resource = response.data
|
||||
return bindCourseResource(this.form.cId, {
|
||||
resourceId: resource.resourceId,
|
||||
const created = response.data || {}
|
||||
return bindCourseResource(this.resourceCourse.cId, {
|
||||
resourceId: created.resourceId,
|
||||
sortNo: this.boundResources.length
|
||||
})
|
||||
}).then(() => {
|
||||
}).then(() => ({
|
||||
...created,
|
||||
resourceType: created.resourceType === null || created.resourceType === undefined ? formType : created.resourceType
|
||||
}))
|
||||
}).then(resource => {
|
||||
this.$modal.msgSuccess('资源已上传并关联')
|
||||
this.resetResourceForm()
|
||||
return Promise.all([this.getResourceOptions(), this.loadCourseResources()])
|
||||
return this.syncAfterResourceAdded(resource)
|
||||
}).finally(() => {
|
||||
this.resourceActionLoading = false
|
||||
})
|
||||
@@ -668,34 +1016,52 @@ export default {
|
||||
},
|
||||
handleUnbind(resource) {
|
||||
this.$modal.confirm(`确定解除资源《${resource.resourceName}》与当前课程的关联吗?资源本身不会被删除。`).then(() => {
|
||||
return unbindCourseResource(this.form.cId, resource.resourceId)
|
||||
return unbindCourseResource(this.resourceCourse.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
|
||||
? `确定停用课程《${row.cName}》吗?停用后该课程将无法被新培训计划选用。`
|
||||
: `确定启用课程《${row.cName}》吗?`
|
||||
this.$modal.confirm(promptText).then(() => {
|
||||
// 原确认与提交逻辑不变,仅抽出供停用前置判断后调用
|
||||
const doUpdate = () => this.$modal.confirm(promptText).then(() => {
|
||||
return updateCourse({ cId: row.cId, status: newStatus })
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess(`${actionText}成功`)
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
// 停用前查询未完成班级引用;启用路径不额外请求,行为不变
|
||||
if (newStatus === 0) {
|
||||
getCourse(row.cId).then(response => {
|
||||
const refCount = Number(response.data && response.data.unfinishedPlanRefCount) || 0
|
||||
if (refCount > 0) {
|
||||
this.$modal.msgWarning(`课程《${row.cName}》已被 ${refCount} 个未完成状态的班级引用,不可停用;请等待班级完成或移除本期课程配置后再试`)
|
||||
return
|
||||
}
|
||||
doUpdate()
|
||||
}).catch(() => {})
|
||||
return
|
||||
}
|
||||
doUpdate()
|
||||
},
|
||||
handleDelete(row) {
|
||||
this.$modal.confirm(`确定删除课程《${row.cName}》吗?`).then(() => {
|
||||
return delCourse(row.cId)
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('删除成功')
|
||||
this.getList()
|
||||
// 与「停用」校验一致:复用详情接口返回的未完成班级引用数进行判断
|
||||
getCourse(row.cId).then(response => {
|
||||
const refCount = Number(response.data && response.data.unfinishedPlanRefCount) || 0
|
||||
if (refCount > 0) {
|
||||
this.$modal.msgWarning(`课程《${row.cName}》已被 ${refCount} 个未完成状态的班级引用,不可删除;请等待班级完成或移除本期课程配置后再试`)
|
||||
return
|
||||
}
|
||||
this.$modal.confirm(`确定删除课程《${row.cName}》吗?`).then(() => {
|
||||
return delCourse(row.cId)
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('删除成功')
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
}).catch(() => {})
|
||||
},
|
||||
resourceLink(resource) {
|
||||
@@ -725,43 +1091,89 @@ export default {
|
||||
this.download('train/course/export', {
|
||||
...this.queryParams
|
||||
}, `course_${new Date().getTime()}.xlsx`)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.course-steps {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.course-form {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.resource-step {
|
||||
max-height: 62vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 4px;
|
||||
}
|
||||
/* 课程资源弹窗:列表为主视觉区域,添加区按需展开 */
|
||||
.course-resource-dialog /deep/ .el-dialog { width: 960px; max-width: 94vw; }
|
||||
.resource-manage { max-height: 78vh; overflow-y: auto; padding: 0 4px; }
|
||||
|
||||
.resource-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
.resource-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.resource-bind-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #e3ebff;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(120deg, #f2f6ff 0%, #eef4ff 60%, #f7fbff 100%);
|
||||
}
|
||||
.resource-hero-info { min-width: 0; }
|
||||
.resource-hero-title { font-size: 16px; font-weight: 700; color: #1f2329; }
|
||||
.resource-hero-sub { margin-top: 6px; font-size: 12px; color: #6b768c; }
|
||||
|
||||
.resource-submit {
|
||||
text-align: right;
|
||||
.resource-main {
|
||||
padding: 14px 16px 16px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(31,41,71,.05);
|
||||
}
|
||||
.resource-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.resource-search { width: 240px; }
|
||||
.resource-filter { width: 140px; }
|
||||
.resource-sort { width: 120px; }
|
||||
.resource-view { margin-left: auto; }
|
||||
.resource-list-scroll { max-height: 42vh; overflow-y: auto; padding-right: 2px; }
|
||||
|
||||
.resource-group { margin-bottom: 16px; }
|
||||
.resource-group:last-child { margin-bottom: 0; }
|
||||
.resource-group-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 13px; font-weight: 600; color: #303133; }
|
||||
.resource-group-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; }
|
||||
.resource-entries { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; }
|
||||
.resource-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: border-color .2s, box-shadow .2s, transform .2s;
|
||||
}
|
||||
.resource-entry:hover { border-color: #9dbcff; box-shadow: 0 4px 12px rgba(43,107,255,.12); transform: translateY(-1px); }
|
||||
.resource-entry-icon { flex: none; display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 8px; font-size: 18px; }
|
||||
.resource-entry-body { flex: 1; min-width: 0; }
|
||||
.resource-entry-name { font-size: 13px; font-weight: 600; color: #1f2329; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.resource-entry-meta { margin-top: 4px; font-size: 11px; color: #909399; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.resource-entry-remove { flex: none; color: #f56c6c; }
|
||||
|
||||
.resource-add-section { margin-top: 16px; padding-top: 14px; border-top: 1px dashed #dfe4ed; }
|
||||
.resource-section-title { margin-bottom: 12px; font-size: 14px; font-weight: 600; color: #303133; }
|
||||
.resource-upload-tip { margin-bottom: 16px; }
|
||||
.resource-bind-table { margin-top: 12px; }
|
||||
.resource-bind-row { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-top: 12px; }
|
||||
.resource-bind-tip { font-size: 12px; color: #909399; }
|
||||
.resource-submit { text-align: right; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.course-resource-dialog /deep/ .el-dialog { width: 94vw; }
|
||||
.resource-hero { flex-wrap: wrap; padding: 12px; }
|
||||
.resource-search, .resource-filter, .resource-sort { width: 100%; }
|
||||
.resource-view { margin-left: 0; }
|
||||
.resource-entries { grid-template-columns: 1fr; }
|
||||
.resource-list-scroll { max-height: 38vh; }
|
||||
.resource-bind-row { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
|
||||
.course-detail {
|
||||
|
||||
@@ -178,14 +178,20 @@
|
||||
/>
|
||||
|
||||
<!-- 创建/编辑培训计划对话框 -->
|
||||
<el-dialog :title="title" :visible.sync="open" width="660px" append-to-body>
|
||||
<el-dialog :title="title" :visible.sync="open" width="1080px" append-to-body>
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="培训计划名称" prop="planName">
|
||||
<el-input v-model="form.planName" maxlength="150" show-word-limit placeholder="请输入培训计划名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="班级名称" prop="className">
|
||||
<el-input v-model="form.className" maxlength="150" show-word-limit placeholder="请输入班级名称" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="培训计划名称" prop="planName">
|
||||
<el-input v-model="form.planName" maxlength="150" show-word-limit placeholder="请输入培训计划名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="班级名称" prop="className">
|
||||
<el-input v-model="form.className" maxlength="150" show-word-limit placeholder="请输入班级名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开班时间" prop="startTime">
|
||||
@@ -216,36 +222,50 @@
|
||||
<el-input-number v-model="form.recruitNum" :min="1" :precision="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="培训地点" prop="trainAddress">
|
||||
<el-input v-model="form.trainAddress" maxlength="200" show-word-limit placeholder="请输入培训地点" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="培训地点" prop="trainAddress">
|
||||
<el-input v-model="form.trainAddress" maxlength="200" show-word-limit placeholder="请输入培训地点" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属专业" prop="classMajor">
|
||||
<el-select v-model="form.classMajor" placeholder="请选择专业" clearable style="width: 100%">
|
||||
<el-option v-for="opt in majorOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="所属专业" prop="classMajor">
|
||||
<el-select v-model="form.classMajor" placeholder="请选择专业" clearable style="width: 100%">
|
||||
<el-option v-for="opt in majorOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="总学时" prop="totalClassHours">
|
||||
<el-input-number v-model="form.totalClassHours" :min="0" :precision="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责人" prop="manager">
|
||||
<el-input v-model="form.manager" maxlength="50" placeholder="请输入负责人" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="主办部门" prop="hostDeptId">
|
||||
<treeselect v-model="form.hostDeptId" :options="deptOptions" :normalizer="normalizer" :show-count="true" placeholder="请选择主办部门" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="报名条件" prop="applyCondition">
|
||||
<el-input v-model="form.applyCondition" type="textarea" :rows="3" placeholder="请输入报名条件" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="主办部门" prop="hostDeptId">
|
||||
<treeselect v-model="form.hostDeptId" :options="deptOptions" :normalizer="normalizer" :show-count="true" placeholder="请选择主办部门" />
|
||||
</el-form-item>
|
||||
<el-form-item label="报名条件" prop="applyCondition">
|
||||
<el-input v-model="form.applyCondition" type="textarea" :rows="3" placeholder="请输入报名条件" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||
|
||||
@@ -30,9 +30,13 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="queryParams.status" placeholder="资源状态" clearable>
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="停用" :value="0" />
|
||||
<el-select v-model="queryParams.status" placeholder="全部状态" clearable>
|
||||
<el-option
|
||||
v-for="item in resourceStatusOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
@@ -106,8 +110,7 @@
|
||||
<el-table-column label="资源描述" align="center" prop="description" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="状态" align="center" prop="status">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.status === 1" type="success">正常</el-tag>
|
||||
<el-tag v-else type="danger">停用</el-tag>
|
||||
<dict-tag :options="dict.type.train_common_status" :value="scope.row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" align="center" prop="createTime" width="160">
|
||||
@@ -115,8 +118,14 @@
|
||||
<span>{{ parseTime(scope.row.createTime) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="180" class-name="small-padding fixed-width">
|
||||
<el-table-column label="操作" align="center" width="250" class-name="small-padding fixed-width">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-view"
|
||||
@click="handlePreview(scope.row)"
|
||||
>预览</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['train:resource:edit']"
|
||||
size="mini"
|
||||
@@ -129,6 +138,7 @@
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-delete"
|
||||
style="color: #F56C6C;"
|
||||
@click="handleDelete(scope.row)"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
@@ -163,11 +173,19 @@
|
||||
<el-input v-model="form.resourceUrl" placeholder="请输入外部教学链接,以 http(s):// 开头" maxlength="500" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="资源文件" prop="resourceUrl">
|
||||
<el-alert
|
||||
:title="uploadTypeTip"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 12px; line-height: 1.5;"
|
||||
/>
|
||||
<file-upload
|
||||
v-model="form.resourceUrl"
|
||||
:limit="1"
|
||||
:file-size="100"
|
||||
:file-type="fileTypes"
|
||||
:file-type="allowedFileTypes"
|
||||
:is-show-tip="false"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="资源描述" prop="description">
|
||||
@@ -175,9 +193,16 @@
|
||||
</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
|
||||
v-for="item in resourceStatusOptions"
|
||||
:key="item.value"
|
||||
:label="item.value"
|
||||
:disabled="isStatusOptionDisabled(item.value)"
|
||||
>{{ item.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
<div v-if="statusDisableBlocked" style="color: #e6a23c; font-size: 12px; line-height: 1.5;">
|
||||
该资源已被 {{ courseReferenceCount }} 门课程引用,不可改为停用;如需停用请先解除课程关联。
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注" maxlength="500" />
|
||||
@@ -188,16 +213,107 @@
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 资源预览:由类型分发表决定渲染方式 -->
|
||||
<el-dialog :title="previewTitle" :visible.sync="previewVisible" width="760px" append-to-body @closed="resetPreview">
|
||||
<div class="preview-box">
|
||||
<img v-if="previewMode === 'image'" :src="previewUrl" class="preview-media" alt="资源预览">
|
||||
<video v-else-if="previewMode === 'video'" :src="previewUrl" controls class="preview-media" />
|
||||
<audio v-else-if="previewMode === 'audio'" :src="previewUrl" controls class="preview-audio" />
|
||||
<iframe v-else-if="previewMode === 'iframe'" :src="previewUrl" class="preview-frame" frameborder="0" />
|
||||
<div v-else class="preview-placeholder">
|
||||
<i class="el-icon-document" />
|
||||
<p>{{ previewTip }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button v-if="previewUrl" type="primary" @click="openPreviewSource">下载 / 打开原文件</el-button>
|
||||
<el-button @click="previewVisible = false">关 闭</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listResource, getResource, addResource, updateResource, delResource } from '@/api/train/resource'
|
||||
|
||||
// 与课程页「上传课程资源」完全一致的格式口径(字典 train_resource_type):
|
||||
// 1 文档 / 2 视频 / 3 3D模型 / 4 VR资源 / 5 外部教学链接(无文件上传)/ 6 其他(用兜底全集)
|
||||
// 如调整课程页 RESOURCE_FILE_TYPES,请同步此处
|
||||
const RESOURCE_FILE_TYPES = {
|
||||
1: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf', 'txt'],
|
||||
2: ['mp4', 'avi', 'mov', 'wmv', 'm4v', 'flv', 'webm', 'mkv', 'mpg', 'mpeg'],
|
||||
3: ['glb', 'gltf', 'obj', 'fbx', 'stl'],
|
||||
4: ['zip', 'rar', '7z', 'glb', 'gltf', 'obj', 'fbx', 'mp4'],
|
||||
5: []
|
||||
}
|
||||
|
||||
// 预览类型判断:优先按文件扩展名,其次按资源类型字典值
|
||||
const PREVIEW_KIND_BY_EXT = {
|
||||
doc: 'document', docx: 'document', xls: 'document', xlsx: 'document', ppt: 'document', pptx: 'document', txt: 'document',
|
||||
pdf: 'pdf',
|
||||
mp4: 'video', avi: 'video', mov: 'video', wmv: 'video', m4v: 'video', flv: 'video', webm: 'video', mkv: 'video', mpg: 'video', mpeg: 'video',
|
||||
png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', bmp: 'image', webp: 'image',
|
||||
mp3: 'audio', wav: 'audio', ogg: 'audio',
|
||||
glb: 'model3d', gltf: 'model3d', obj: 'model3d', fbx: 'model3d', stl: 'model3d',
|
||||
zip: 'archive', rar: 'archive', '7z': 'archive'
|
||||
}
|
||||
const PREVIEW_KIND_BY_TYPE = { 1: 'document', 2: 'video', 3: 'model3d', 4: 'vr', 5: 'link', 6: 'other' }
|
||||
const PREVIEW_KIND_LABELS = {
|
||||
image: '图片', video: '视频', audio: '音频', pdf: 'PDF 文档', document: '文档',
|
||||
model3d: '3D 模型', vr: 'VR 资源', archive: '压缩包', link: '外部链接', other: '其他', unknown: '未识别类型'
|
||||
}
|
||||
/**
|
||||
* 预览方法分发表:kind -> 组件方法名,unsupported 表示仅给出不支持提示。
|
||||
* 扩展方式:新增类型只需在 PREVIEW_KIND_BY_EXT / PREVIEW_KIND_BY_TYPE 中登记 kind,
|
||||
* 再在此表登记处理方法(可复用已有方法,或新增一个 openXxxPreview 方法),分发逻辑无需改动。
|
||||
*/
|
||||
const PREVIEW_HANDLERS = {
|
||||
image: 'openImagePreview',
|
||||
video: 'openVideoPreview',
|
||||
audio: 'openAudioPreview',
|
||||
pdf: 'openEmbedPreview',
|
||||
document: 'openDownloadPreview',
|
||||
link: 'openLinkPreview',
|
||||
model3d: 'unsupported',
|
||||
vr: 'unsupported',
|
||||
archive: 'unsupported',
|
||||
other: 'unsupported'
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'TrainResource',
|
||||
dicts: ['train_resource_type'],
|
||||
dicts: ['train_resource_type', 'train_common_status'],
|
||||
data() {
|
||||
// 提交时二次校验:确保已选文件/地址仍符合当前资源类型(选文件时已由上传组件即时拦截)
|
||||
const validateResourceUrl = (rule, value, callback) => {
|
||||
if (!value) {
|
||||
callback(new Error('资源文件或外部链接不能为空'))
|
||||
return
|
||||
}
|
||||
// 修改时未改动的历史地址不重新校验,避免已有数据因新增的前端规则无法保存
|
||||
if (this.originalResourceUrl && value === this.originalResourceUrl) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
const type = Number(this.form.resourceType)
|
||||
if (type === 5) {
|
||||
if (!/^https?:\/\//i.test(value)) {
|
||||
callback(new Error('外部链接必须以 http:// 或 https:// 开头'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
return
|
||||
}
|
||||
const allowed = RESOURCE_FILE_TYPES[type] || this.fileTypes
|
||||
const path = String(value).split('?')[0].split('#')[0]
|
||||
const ext = path.substring(path.lastIndexOf('.') + 1).toLowerCase()
|
||||
if (allowed.indexOf(ext) === -1) {
|
||||
callback(new Error(`当前资源类型仅支持 ${allowed.join(' / ')} 格式,请重新选择文件`))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
return {
|
||||
// 遮罩层
|
||||
loading: true,
|
||||
@@ -229,6 +345,19 @@ export default {
|
||||
},
|
||||
// 表单参数
|
||||
form: {},
|
||||
// 预览弹窗状态(仅由操作栏「预览」入口使用)
|
||||
previewVisible: false,
|
||||
previewMode: '',
|
||||
previewUrl: '',
|
||||
previewTitle: '资源预览',
|
||||
previewTip: '',
|
||||
// 修改场景的原始资源地址(用于跳过历史值的重复格式校验)
|
||||
originalResourceUrl: null,
|
||||
// 修改弹窗回填表单期间跳过「切换类型清空文件」逻辑
|
||||
initializingForm: false,
|
||||
// 编辑窗口打开时的原状态与课程引用数,用于判断能否停用
|
||||
originalStatus: null,
|
||||
courseReferenceCount: 0,
|
||||
// 表单校验
|
||||
rules: {
|
||||
resourceName: [
|
||||
@@ -238,12 +367,51 @@ export default {
|
||||
{ required: true, message: '资源类型不能为空', trigger: 'change' }
|
||||
],
|
||||
resourceUrl: [
|
||||
{ required: true, message: '资源文件或外部链接不能为空', trigger: ['blur', 'change'] }
|
||||
{ required: true, message: '资源文件或外部链接不能为空', trigger: ['blur', 'change'] },
|
||||
{ validator: validateResourceUrl, trigger: ['blur', 'change'] }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 资源状态统一取自字典 train_common_status,值由字典维护,页面不写死 1/0
|
||||
resourceStatusOptions() {
|
||||
return (this.dict.type.train_common_status || []).map(item => ({
|
||||
label: item.label,
|
||||
value: /^-?\d+$/.test(String(item.value)) ? Number(item.value) : item.value,
|
||||
raw: item.raw || {}
|
||||
}))
|
||||
},
|
||||
/** 「正常」状态值:优先字典默认项,其次按 primary 样式/正常文案识别 */
|
||||
normalStatusValue() {
|
||||
const hit = this.resourceStatusOptions.find(item => {
|
||||
const raw = item.raw || {}
|
||||
return raw.isDefault === 'Y' || raw.isDefault === true || raw.listClass === 'primary' || String(item.label).indexOf('正常') !== -1
|
||||
})
|
||||
return hit ? hit.value : null
|
||||
},
|
||||
/** 新增资源默认状态;字典尚未就绪时取字典首项,仍不写死数值 */
|
||||
defaultResourceStatus() {
|
||||
if (this.normalStatusValue != null) return this.normalStatusValue
|
||||
return this.resourceStatusOptions.length ? this.resourceStatusOptions[0].value : null
|
||||
},
|
||||
/** 「停用」状态值:字典中除「正常」之外的状态项 */
|
||||
disabledStatusValue() {
|
||||
const normal = this.normalStatusValue
|
||||
const hit = this.resourceStatusOptions.find(item => normal != null && String(item.value) !== String(normal))
|
||||
return hit ? hit.value : null
|
||||
},
|
||||
/**
|
||||
* 限制触发条件:修改场景 + 资源已被课程引用 + 打开窗口时为「正常」状态。
|
||||
* 作用范围:仅编辑窗口的状态单选与提交;新增、未绑定、原本已停用的资源不受影响。
|
||||
* 预期行为:禁止选择停用并提示先解除课程关联,后端 updateTrainResource 做同等兜底。
|
||||
*/
|
||||
statusDisableBlocked() {
|
||||
return this.form.resourceId != null &&
|
||||
this.courseReferenceCount > 0 &&
|
||||
this.normalStatusValue != null &&
|
||||
String(this.originalStatus) === String(this.normalStatusValue)
|
||||
},
|
||||
// 资源类型下拉项:字典值为字符串,数据表字段为整型,需转成数字
|
||||
resourceTypeOptions() {
|
||||
return (this.dict.type.train_resource_type || []).map(item => ({
|
||||
@@ -254,11 +422,26 @@ export default {
|
||||
// 资源类型为「外部教学链接」时不走上传,改为填写地址
|
||||
isExternalLink() {
|
||||
return Number(this.form.resourceType) === 5
|
||||
},
|
||||
// 由资源类型选择器决定允许上传的格式,与课程页「上传课程资源」一致
|
||||
allowedFileTypes() {
|
||||
return RESOURCE_FILE_TYPES[Number(this.form.resourceType)] || this.fileTypes
|
||||
},
|
||||
uploadTypeTip() {
|
||||
if (this.isExternalLink) {
|
||||
return '外部教学链接无需上传文件,请填写以 http(s):// 开头的地址。'
|
||||
}
|
||||
const list = this.dict.type.train_resource_type || []
|
||||
const hit = list.find(item => String(item.value) === String(this.form.resourceType))
|
||||
const label = hit ? hit.label : '当前资源类型'
|
||||
return `当前资源类型为「${label}」,仅支持 ${this.allowedFileTypes.join(' / ')} 格式,单个文件不超过 100MB;切换资源类型后限制会同步更新。`
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// 切换资源类型时清空已选资源地址,避免类型与地址不匹配
|
||||
'form.resourceType'(newVal, oldVal) {
|
||||
// 修改弹窗回填数据时属于初始化,不是用户切换类型,不能清空原有文件地址
|
||||
if (this.initializingForm) return
|
||||
if (oldVal !== undefined && newVal !== oldVal && this.open) {
|
||||
this.$set(this.form, 'resourceUrl', null)
|
||||
this.$nextTick(() => {
|
||||
@@ -277,7 +460,7 @@ export default {
|
||||
goBackCourse() {
|
||||
this.$router.push('/equip/course')
|
||||
},
|
||||
/** 查询教学资源列表 */
|
||||
/** 查询教学资源列表:默认不带状态条件,展示全部状态;状态仅作为用户可选的显式筛选 */
|
||||
getList() {
|
||||
this.loading = true
|
||||
listResource(this.queryParams).then(response => {
|
||||
@@ -291,6 +474,12 @@ export default {
|
||||
this.open = false
|
||||
this.reset()
|
||||
},
|
||||
/** 已绑定课程且原状态为正常的资源,禁止选择「停用」 */
|
||||
isStatusOptionDisabled(value) {
|
||||
return this.statusDisableBlocked &&
|
||||
this.disabledStatusValue != null &&
|
||||
String(value) === String(this.disabledStatusValue)
|
||||
},
|
||||
// 表单重置
|
||||
reset() {
|
||||
this.form = {
|
||||
@@ -299,9 +488,13 @@ export default {
|
||||
resourceType: 1,
|
||||
resourceUrl: null,
|
||||
description: null,
|
||||
status: 1,
|
||||
status: this.defaultResourceStatus,
|
||||
remark: null
|
||||
}
|
||||
this.originalResourceUrl = null
|
||||
this.initializingForm = false
|
||||
this.originalStatus = null
|
||||
this.courseReferenceCount = 0
|
||||
this.resetForm('form')
|
||||
},
|
||||
/** 搜索按钮操作 */
|
||||
@@ -329,22 +522,36 @@ export default {
|
||||
/** 修改按钮操作 */
|
||||
handleUpdate(row) {
|
||||
this.reset()
|
||||
this.initializingForm = true
|
||||
const resourceId = row.resourceId || this.ids
|
||||
getResource(resourceId).then(response => {
|
||||
// 课程引用数是详情接口附带的派生字段,不参与表单提交
|
||||
const { courseReferenceCount, ...resourceForm } = response.data
|
||||
// 资源类型为整型,防止后端返回字符串导致下拉/单选回显失败
|
||||
this.form = {
|
||||
...response.data,
|
||||
...resourceForm,
|
||||
resourceType: response.data.resourceType == null ? null : Number(response.data.resourceType),
|
||||
status: response.data.status == null ? 1 : Number(response.data.status)
|
||||
status: response.data.status == null ? this.defaultResourceStatus : Number(response.data.status)
|
||||
}
|
||||
this.courseReferenceCount = Number(courseReferenceCount) || 0
|
||||
this.originalStatus = this.form.status
|
||||
this.originalResourceUrl = this.form.resourceUrl || null
|
||||
this.open = true
|
||||
this.title = '修改教学资源'
|
||||
this.$nextTick(() => {
|
||||
this.initializingForm = false
|
||||
})
|
||||
})
|
||||
},
|
||||
/** 提交按钮 */
|
||||
submitForm() {
|
||||
this.$refs['form'].validate(valid => {
|
||||
if (valid) {
|
||||
// 前置拦截:即使绕过禁用的单选项,也不提交已绑定资源的停用变更
|
||||
if (this.statusDisableBlocked && this.isStatusOptionDisabled(this.form.status)) {
|
||||
this.$modal.msgWarning('该资源已被课程引用,不可停用,请先解除课程关联')
|
||||
return
|
||||
}
|
||||
if (this.form.resourceId != null) {
|
||||
updateResource(this.form).then(response => {
|
||||
this.$modal.msgSuccess('修改成功')
|
||||
@@ -368,11 +575,20 @@ export default {
|
||||
this.$modal.msgWarning('请选择要删除的数据')
|
||||
return
|
||||
}
|
||||
this.$modal.confirm('是否确认删除资源编号为"' + ids + '"的数据项?').then(function() {
|
||||
return delResource(ids)
|
||||
}).then(() => {
|
||||
this.getList()
|
||||
this.$modal.msgSuccess('删除成功')
|
||||
const idList = Array.isArray(ids) ? ids : [ids]
|
||||
// 与「停用」校验一致:复用详情接口返回的课程引用数进行判断
|
||||
Promise.all(idList.map(id => getResource(id))).then(list => {
|
||||
const blocked = list.some(response => Number(response.data && response.data.courseReferenceCount) > 0)
|
||||
if (blocked) {
|
||||
this.$modal.msgWarning('所选资源已被课程引用,不可删除;请先解除课程关联')
|
||||
return
|
||||
}
|
||||
this.$modal.confirm('是否确认删除资源编号为"' + ids + '"的数据项?').then(function() {
|
||||
return delResource(ids)
|
||||
}).then(() => {
|
||||
this.getList()
|
||||
this.$modal.msgSuccess('删除成功')
|
||||
}).catch(() => {})
|
||||
}).catch(() => {})
|
||||
},
|
||||
// 资源文件访问地址
|
||||
@@ -391,7 +607,99 @@ export default {
|
||||
return ''
|
||||
}
|
||||
return url.substring(url.lastIndexOf('/') + 1)
|
||||
},
|
||||
/**
|
||||
* 操作栏「预览」入口:只处理当前行,不改动查询、分页、表单及选中状态
|
||||
*/
|
||||
handlePreview(row) {
|
||||
if (!row || !row.resourceUrl) {
|
||||
this.$modal.msgWarning('资源地址无效,无法预览')
|
||||
return
|
||||
}
|
||||
this.dispatchPreview(this.resolvePreviewKind(row), row, this.fileUrl(row.resourceUrl))
|
||||
},
|
||||
/** 类型判断:文件扩展名优先,其次资源类型字典值,均无法匹配时标记 unknown */
|
||||
resolvePreviewKind(row) {
|
||||
const path = String(row.resourceUrl || '').split('?')[0].split('#')[0]
|
||||
const dot = path.lastIndexOf('.')
|
||||
const ext = dot === -1 ? '' : path.substring(dot + 1).toLowerCase()
|
||||
const byExt = PREVIEW_KIND_BY_EXT[ext]
|
||||
if (byExt) return byExt
|
||||
return PREVIEW_KIND_BY_TYPE[Number(row.resourceType)] || 'unknown'
|
||||
},
|
||||
/** 预览方法分发:新增类型只需扩展常量表,不改此处逻辑 */
|
||||
dispatchPreview(kind, row, url) {
|
||||
const handler = PREVIEW_HANDLERS[kind]
|
||||
const label = PREVIEW_KIND_LABELS[kind] || '未知类型'
|
||||
if (kind === 'unknown' || !handler) {
|
||||
this.$modal.msgWarning('无法识别该资源类型,暂不支持预览')
|
||||
return
|
||||
}
|
||||
if (handler === 'unsupported') {
|
||||
this.$modal.msgWarning(`「${label}」暂不支持在线预览,可点击资源地址下载后查看`)
|
||||
return
|
||||
}
|
||||
if (typeof this[handler] !== 'function') {
|
||||
this.$modal.msgWarning('预览方法未配置,请联系管理员')
|
||||
return
|
||||
}
|
||||
this[handler](row, url, label)
|
||||
},
|
||||
setPreview(mode, url, row, tip) {
|
||||
this.previewMode = mode
|
||||
this.previewUrl = url
|
||||
this.previewTitle = (row.resourceName || '资源预览') + ' · 预览'
|
||||
this.previewTip = tip || ''
|
||||
this.previewVisible = true
|
||||
},
|
||||
openImagePreview(row, url) {
|
||||
this.setPreview('image', url, row)
|
||||
},
|
||||
openVideoPreview(row, url) {
|
||||
this.setPreview('video', url, row)
|
||||
},
|
||||
openAudioPreview(row, url) {
|
||||
this.setPreview('audio', url, row)
|
||||
},
|
||||
openEmbedPreview(row, url) {
|
||||
this.setPreview('iframe', url, row)
|
||||
},
|
||||
openDownloadPreview(row, url, label) {
|
||||
this.setPreview('other', url, row, `「${label}」不支持在线预览,可点击下方按钮打开或下载原文件。`)
|
||||
},
|
||||
openLinkPreview(row, url) {
|
||||
window.open(url, '_blank')
|
||||
},
|
||||
openPreviewSource() {
|
||||
if (this.previewUrl) {
|
||||
window.open(this.previewUrl, '_blank')
|
||||
}
|
||||
},
|
||||
resetPreview() {
|
||||
this.previewMode = ''
|
||||
this.previewUrl = ''
|
||||
this.previewTip = ''
|
||||
this.previewTitle = '资源预览'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 260px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #f7f8fa;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-media { display: block; max-width: 100%; max-height: 60vh; }
|
||||
.preview-audio { width: 100%; }
|
||||
.preview-frame { width: 100%; height: 60vh; border: 0; }
|
||||
.preview-placeholder { text-align: center; color: #909399; }
|
||||
.preview-placeholder i { font-size: 42px; }
|
||||
.preview-placeholder p { margin-top: 12px; font-size: 13px; }
|
||||
</style>
|
||||
Reference in new issue
Block a user