课程资源分上传资源、选取已有资源;课程被班级关联时不可停用(删除)课程;学员导入模板

This commit is contained in:
buliping committed 2026-11-12 08:52:21 +08:00
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 下游引用)。
*
@@ -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("课程已被未完成状态的班级引用,不可停用;请等待班级完成、取消计划或移除本期课程配置后再试");
}
}
/**
* 校验课程统一业务编码是否唯一
*
@@ -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
@@ -1256,6 +1256,11 @@ public class SchedulingAppServiceTest
return 0;
}
@Override
public int countUnfinishedPlanCoursesByCId(String cId) {
return 0;
}
@Override
public int countStudentScoresByCId(String cId)
{
@@ -622,6 +622,11 @@ public class SchedulingTeacherStatusAndOptionsTest
return 0;
}
@Override
public int countUnfinishedPlanCoursesByCId(String cId) {
return 0;
}
@Override
public int countStudentScoresByCId(String cId)
{
@@ -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
+2 -1
View File
@@ -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
})
+579 -167
View File
@@ -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 {
+44 -24
View File
@@ -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>
+328 -20
View File
@@ -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>