浏览代码

Merge branch 'cxmo_branch'

cxmo 10 月之前
父节点
当前提交
534c9d28ba

+ 2 - 0
index.html

@@ -4,6 +4,8 @@
     <meta charset="UTF-8" />
     <link rel="icon" type="image/x-icon" href="./static/fa.png" id="icon"/>
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <!-- oss SDK -->
+	<script type="text/javascript" src="https://gosspublic.alicdn.com/aliyun-oss-sdk-6.16.0.min.js"></script>
     <title>crm</title>
   </head>
   <body>

+ 47 - 0
src/router/modules/trainingRoutes.js

@@ -0,0 +1,47 @@
+//培训管理路由模块
+import Home from '@/layouts/index.vue'
+export default[
+    {
+        path:'/',
+        component:Home,
+        name:'trainingManage',
+        hidden:false,
+        meta: {
+            title: "培训管理",
+        },
+        children:[
+            {
+                path: "trainingVideo",
+                name: "trainingVideo",
+                component: () => import('@/views/training_manage/videoManage.vue'),
+                meta: {
+                    title: "视频管理",
+                },
+            },
+            {
+                path: "trainingLabel",
+                name: "trainingLabel",
+                component: () => import('@/views/training_manage/labelManage.vue'),
+                meta: {
+                    title: "标签管理",
+                },
+            },
+            {
+                path: "trainingClassify",
+                name: "trainingClassify",
+                component: () => import('@/views/training_manage/classifyManage.vue'),
+                meta: {
+                    title: "分类管理",
+                },
+            },
+            {
+                path:'modifyVideo',
+                name:'modifyVideo',
+                component:()=> import('@/views/training_manage/modifyVideoPage.vue'),
+                meta: {
+                    title: "编辑视频",
+                },
+            }
+        ]
+    }
+]

+ 174 - 0
src/views/training_manage/classifyManage.vue

@@ -0,0 +1,174 @@
+<script setup>
+import { reactive, ref, watch, computed, nextTick } from 'vue'
+import { Search } from '@element-plus/icons-vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import {ClassifyInterface} from '@/api/modules/trainingApi'
+import _ from 'lodash'
+
+let searchText = ref('')
+let tableData = ref([])
+let tableLoading = ref(false)
+
+let defaultList = ref([{ClassifyId:-1,ClassifyName:'无'}])
+const rules = reactive({
+    ParentId:[{required:true,message:'请选择上级分类'}],
+    ClassifyName:[{required:true,message:'请输入分类名称'}]
+})
+
+const optionList = computed(()=>{
+    const list = tableData.value.map(i=>{
+        return {
+            ClassifyId:i.ClassifyId,
+            ClassifyName:i.ClassifyName
+        }
+    })
+    return list
+})
+
+
+let isModifyDialogShow = ref(false)
+let formRef = ref(null)
+watch(isModifyDialogShow,(newVal)=>{
+    if(newVal){
+        nextTick(()=>{
+            formRef.value?.clearValidate()
+        })
+    }
+})
+let currentClassify = ref({})
+function handleModifyClassify(data){
+    currentClassify.value = _.cloneDeep(data)
+    if(data.ParentId===0){
+        currentClassify.value.ParentId = -1
+    }
+    isModifyDialogShow.value = true
+}
+//添加编辑分类
+async function modifyClassify(){
+    try{
+        await formRef.value?.validate()
+    }catch(e){
+        console.log(e)
+        return 
+    }
+    
+    let res = null
+    //params对ParentId为-1的数据做处理,转为0
+    if(currentClassify.value.ClassifyId){
+        res = await ClassifyInterface.editClassify({
+            ClassifyId:currentClassify.value.ClassifyId,
+            ParentId:currentClassify.value.ParentId===-1?0:currentClassify.value.ParentId,
+            ClassifyName:currentClassify.value.ClassifyName
+        })
+    }else{
+        res = await ClassifyInterface.addClassify({
+            ParentId:currentClassify.value.ParentId===-1?0:currentClassify.value.ParentId,
+            ClassifyName:currentClassify.value.ClassifyName
+        })
+    }
+    if(res.Ret!==200) return 
+    ElMessage.success(`${currentClassify.value.ClassifyId?'编辑':'添加'}成功`)
+    getTableData()
+    isModifyDialogShow.value = false
+}
+function getTableData(){
+    ClassifyInterface.getClassifyList({
+        Keyword:searchText.value
+    }).then(res=>{
+        if(res.Ret!==200) return
+        tableData.value = res.Data&&res.Data.List||[]
+    })
+}
+getTableData()
+function deleteClassify(data){
+    if(data.Children&&data.Children.length){
+        ElMessageBox.confirm(
+            '该分类下已关联内容,不可删除!',
+            '提示',
+            {
+                confirmButtonText: '知道了',
+                showCancelButton:false,
+                type: 'error',
+            }
+        ).then(()=>{})
+    }else{
+        ElMessageBox.confirm(
+            '删除后不可恢复,是否确认删除?',
+            '提示',
+            {
+                confirmButtonText: '确定',
+                cancelButtonText: '取消',
+                type: 'warning',
+            }
+        ).then(()=>{
+            ClassifyInterface.deleteClassify({
+                ClassifyId:data.ClassifyId
+            }).then(res=>{
+                if(res.Ret!==200) return 
+                ElMessage.success('删除成功')
+                getTableData()
+            })
+            
+        })
+    }
+}
+</script>
+<template>
+    <div class="classify-manage-wrap traing-manage">
+        <div class="top-wrap">
+            <el-button type="primary" @click="handleModifyClassify({})">添加分类</el-button>
+            <el-input v-model="searchText" clearable :prefix-icon="Search" placeholder="请输入分类名称"
+                @input="getTableData" style="width:240px;"></el-input>
+        </div>
+        <div class="table-wrap">
+            <el-table :data="tableData" v-loading="tableLoading" row-key="ClassifyId"
+                :tree-props="{children:'Children',hasChildren:'hasChildren'}">
+                <el-table-column prop="ClassifyName" label="一级分类" align="center">
+                    <template #default="{row}">
+                        <span>{{row.ParentId?'':row.ClassifyName}}</span>
+                    </template>
+                </el-table-column>
+                <el-table-column prop="ClassifyName" label="二级分类" align="center">
+                    <template #default="{row}">
+                        <span>{{row.ParentId?row.ClassifyName:''}}</span>
+                    </template>
+                </el-table-column>
+                <el-table-column label="操作" align="center" width="400px">
+                    <template #default="{row}">
+                        <el-link :underline="false" type="primary" style="margin-right: 20px;" @click="handleModifyClassify(row)">编辑</el-link>
+                        <el-link :underline="false" type="danger" @click="deleteClassify(row)">删除</el-link>
+                    </template>
+                </el-table-column>
+            </el-table>
+        </div>
+        <!-- 添加分类弹窗 -->
+        <el-dialog :title="currentClassify.ClassifyId?'编辑分类':'添加分类'" v-model="isModifyDialogShow"
+            :close-on-click-modal="false" :modal-append-to-body="false" @close="isModifyDialogShow=false" width="589px" center>
+            <div class="dialog-container">
+                <el-form :model="currentClassify" :rules="rules" ref="formRef" label-position="top">
+                    <el-form-item label="上级分类" prop="ParentId">
+                        <el-select v-model="currentClassify.ParentId" placeholder="请选择上级分类" style="width:100%;">
+                            <el-option v-for="item in currentClassify.ClassifyId
+                                ?(currentClassify.ParentId<=0?defaultList:optionList)
+                                :[...defaultList,...optionList]" 
+                            :key="item.ClassifyId"
+                            :label="item.ClassifyName"
+                            :value="item.ClassifyId"/>
+                        </el-select>
+                    </el-form-item>
+                    <el-form-item label="分类名称" prop="ClassifyName">
+                        <el-input v-model="currentClassify.ClassifyName" placeholder="请输入分类名称" style="width:100%;"></el-input>
+                    </el-form-item>
+                </el-form>
+            </div>
+            <div class="foot-container">
+                <el-button @click="isModifyDialogShow=false">取 消</el-button>
+                <el-button type="primary" @click="modifyClassify">确认</el-button>
+            </div>
+        </el-dialog>
+    </div>
+</template>
+
+<style scoped lang="scss">
+@import "./css/manage.scss";
+</style>

+ 159 - 0
src/views/training_manage/components/addTags.vue

@@ -0,0 +1,159 @@
+<script setup>
+import {TagInterface} from '@/api/modules/trainingApi'
+import { ref, reactive, watch, nextTick } from 'vue'
+import { Search } from '@element-plus/icons-vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import _ from 'lodash'
+
+const props = defineProps({
+    isModifyDialogShow:{
+        type:Boolean,
+        default:false
+    },
+    Tags:{
+        type:Array,
+        default:[]
+    },
+    getTagList:{
+        type:Function,
+        default:null
+    },
+    tagList:{
+        type:Array,
+        default:[]
+    }
+})
+const emit = defineEmits(["modify","close"])
+let searchText = ref('')
+let addText = ref('')
+let choosedTags = ref([])
+watch(()=>props.isModifyDialogShow,(newVal)=>{
+    if(newVal){
+        searchText.value =''
+        addText.value = ''
+        choosedTags.value = _.cloneDeep(props.Tags)
+        props.getTagList()
+    }
+})
+//选择标签
+function chooseTag(tag){
+    const {TagId} = tag
+    const index = choosedTags.value.findIndex(i=>i.TagId===TagId)
+    if(index!==-1){
+        choosedTags.value.splice(index,1)
+    }else{
+        if(choosedTags.value.length===3){
+            ElMessage.warning("最多选择3个标签")
+            return
+        }
+        choosedTags.value.push(tag)
+    }
+}
+//添加标签
+function addTag(){
+    if(!addText.value){
+        ElMessage.warning("请输入标签名称")
+        return
+    }
+    if(addText.value.length>5){
+        ElMessage.warning("标签名称过长,请重新编辑")
+        return
+    }
+    TagInterface.addTag({
+        TagName:addText.value
+    }).then(res=>{
+        if(res.Ret!==200) return 
+        props.getTagList()
+        addText.value = ''
+    })
+}
+function modifyTags(){
+    emit('modify',choosedTags.value)
+}
+function toTagPage(){
+    window.open('/trainingLabel')
+}
+</script>
+<template>
+    <el-dialog
+        title="选择标签"
+        :model-value="props.isModifyDialogShow"
+        :close-on-click-modal="false"
+        :modal-append-to-body="false"
+        @close="emit('close')"
+        width="589px"
+        center
+        class="add-Tags-wrap"
+    >
+        <div class="dialog-container">
+            <el-input  placeholder="请输入标签名称" :prefix-icon="Search"
+                v-model.trim="searchText" clearable @input="getTagList(searchText)"></el-input>
+            <el-link :underline="false" type="primary" @click="getTagList(searchText)">搜索</el-link>
+            <div class="tag-list-box">
+                <el-tag v-for="item in tagList"
+                    :class="['tag-item',{ choosed: choosedTags.findIndex(i=>i.TagId===item.TagId)!==-1 }]"
+                    :key="item.TagId"
+                    type="info"
+                    effect="plain"
+                    @click="chooseTag(item)"
+                >
+                    {{ item.TagName }}
+                </el-tag>
+            </div>
+            <div class="add-tag-box">
+                <el-input  placeholder="请输入标签名称" v-model.trim="addText"></el-input>
+                <el-link :underline="false" type="primary" style="margin-right: 20px;" @click="addTag">添加</el-link>
+                <el-link :underline="false" type="primary"  @click="toTagPage">标签管理</el-link>
+                <p style="color:#999999;font-size: 12px;">注:名称不得超过5个字</p>
+            </div>
+        </div>
+        <div class="foot-container">
+            <el-button @click="emit('close')">取 消</el-button>
+            <el-button type="primary" @click="modifyTags">确认</el-button>
+        </div>
+    </el-dialog>
+</template>
+
+<style scoped lang="scss">
+.add-Tags-wrap{
+    .el-dialog__body{
+        .dialog-container{
+            .tag-list-box{
+                margin-top:20px;
+                padding:10px;
+                box-sizing: border-box;
+                border: 1px dashed #DCDFE6;
+                border-radius: 4px;
+                max-height: 200px;
+                overflow-y: auto;
+                display: flex;
+                flex-wrap: wrap;
+                gap:10px;
+                .tag-item{
+                    cursor: pointer;
+                    box-sizing: border-box;
+                    text-align: center;
+                    min-width:78px;
+                    /* border:1px solid black; */
+                    border-radius: 2px;
+                    &:hover,&.choosed{
+                        background-color: #EAF3FE;
+                        border-color: #409EFF;
+                        color: #409EFF;
+                    }
+                }
+            }
+            .add-tag-box{
+                margin-top:20px;
+            }
+            .el-input{
+                margin-right: 10px;
+            }
+        }
+        .foot-container{
+            text-align: center;
+            padding:20px 0;
+        }
+    }
+}
+</style>

+ 42 - 0
src/views/training_manage/config/tableColumn.js

@@ -0,0 +1,42 @@
+export const labelTableColumn = [
+    {
+        label:'标签名称',
+        key:'TagName',
+        minWidth:'200px',
+    },{
+        label:'视频数',
+        key:'VideoTotal',
+    },{
+        label:'创建时间',
+        key:'CreateTime',
+        minWidth:'200px',
+    }
+]
+
+
+export const videoTableColumn = [
+    {
+        label:'视频封面',
+        key:'CoverImg',
+        Width:'300px',
+    },{
+        label:'视频名称',
+        key:'Title',
+        minWidth:'200px'
+    },{
+        label:'分类',
+        key:'Classify',
+        Width:'200px',
+    },{
+        label:'标签',
+        key:'Tags',
+        minWidth:'200px',
+    },{
+        label:'状态',
+        key:'PublishState',
+    },{
+        label:'创建时间',
+        key:'CreateTime',
+        Width:'200px',
+    }
+]

+ 39 - 0
src/views/training_manage/css/manage.scss

@@ -0,0 +1,39 @@
+.traing-manage{
+    .top-wrap,.table-wrap{
+        padding:20px;
+        background-color:#fff;
+        border-radius: 4px;
+    }
+    .top-wrap{
+        display: flex;
+        justify-content: space-between;
+        .select-box{
+            display: flex;
+            gap:0 10px;
+        }
+    }
+    .table-wrap{
+        margin-top: 20px;
+    }
+    .el-dialog{
+        .dialog-container{
+          padding-bottom: 20px;
+          :deep(.el-select){
+            .el-input{
+                width:100%;
+            }
+          }
+          .input-item{
+            margin-bottom:10px;
+          }
+          .form-hint{
+            color:#999999;
+            font-size: 12px;
+          }
+        }
+        .foot-container{
+            text-align: center;
+            padding-bottom: 20px;
+        }
+      }
+}

+ 72 - 0
src/views/training_manage/hooks/use-video.js

@@ -0,0 +1,72 @@
+import { ref } from 'vue'
+import {TagInterface,ClassifyInterface} from '@/api/modules/trainingApi'
+
+export function useVideo(){
+    let classifyList = ref([])
+    function getClassifyList(type=''){
+        ClassifyInterface.getClassifyList({}).then(res=>{
+            if(res.Ret!==200) return 
+            classifyList.value = res.Data&&res.Data.List||[]
+            classifyList.value = filterNodes(classifyList.value)
+            classifyList.value = classifyList.value.map(item=>{
+                if(!item.Children){
+                    item.disabled = true
+                }
+                return item
+            })
+        })
+    }
+    function filterNodes(arr) {
+        arr.length && arr.forEach(item => {
+            item.Children && item.Children.length && filterNodes(item.Children)
+            if(item.Children && !item.Children.length) {
+                delete item.Children
+            }
+        })
+        return arr
+    }
+    //获取视频分类路径
+    function getDataClassify(classify,classifyArr=[],propName='ClassifyName'){
+        classifyArr.push(classify[propName])
+        if(classify.Children&&classify.Children.length){
+            return getDataClassify(classify.Children[0],classifyArr,propName)
+        }
+        return classifyArr
+    }
+
+    let tagList = ref([])
+    function getTagList(keyword=''){
+        TagInterface.getTagList({
+            Keyword:keyword,
+            PageSize:1000,
+            CurrentIndex:1
+        }).then(res=>{
+            if(res.Ret!==200) return 
+            tagList.value = res.Data&&res.Data.List||[]
+        })
+    }
+
+    let previewPop = ref(false)
+    let previewVideo = ref(null)
+    let previewVideoUrl = ref('')
+    let previewPopTitle = ref('')
+    function handlePreviewVideo(data){
+        if(!data.VideoUrl) return
+        previewVideo.value?.play()
+        previewPopTitle.value = data.Title||'暂无标题'
+        previewVideoUrl.value = data.VideoUrl
+        previewPop.value = true
+    }
+    // 结束预览弹窗关闭回调 -- 暂停视频
+    function endingPreview(){
+        previewVideo.value?.pause()
+    }
+
+    return {
+        classifyList,getClassifyList,
+        tagList,getTagList,
+        getDataClassify,
+        previewPop,previewVideo,previewVideoUrl,previewPopTitle,
+        handlePreviewVideo,endingPreview
+    }
+}

+ 158 - 0
src/views/training_manage/labelManage.vue

@@ -0,0 +1,158 @@
+<script setup>
+import { reactive, ref, watch, computed, nextTick } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Search } from '@element-plus/icons-vue'
+import {labelTableColumn} from './config/tableColumn'
+import {TagInterface} from '@/api/modules/trainingApi'
+import _ from 'lodash'
+
+
+/* table */
+const tableLoading = ref(false)
+let tableData = ref([])
+const tableColumn = labelTableColumn
+const tableParams = reactive({
+    searchText:'',
+    /* table-page */
+    currentPage:1,
+    pageSize:10,
+    total:0,
+})
+function getTableData(){
+    tableLoading.value = true
+    TagInterface.getTagList({
+        PageSize:tableParams.pageSize,
+        CurrentIndex:tableParams.currentPage,
+        Keyword:tableParams.searchText
+    }).then(res=>{
+        tableLoading.value = false
+        if(res.Ret!==200) return
+        if(!res.Data){
+            tableData.value = []
+            tableParams.total = 0
+            return
+        } 
+        tableData.value = res.Data.List||[]
+        tableParams.total = res.Data.Paging.Totals
+    })
+}
+getTableData()
+function handleCurrentChange(page){
+    tableParams.currentPage = page
+    getTableData()
+}
+/* modify label */
+let currentLabel = ref({})
+let isModifyDialogShow = ref(false)
+function handleModifyLabel(data){
+    currentLabel.value = _.cloneDeep(data)
+    isModifyDialogShow.value = true
+}
+async function modifyLabel(){
+    const {TagId,TagName} = currentLabel.value
+    if(!TagName){
+        ElMessage.warning("请输入标签名称")
+        return
+    }
+    if(TagName.length>5){
+        ElMessage.warning("标签名称过长,请重新编辑")
+        return
+    }
+    let res = null
+    if(TagId){
+        //edit
+        res = await TagInterface.editTag({TagId,TagName})
+    }else{
+        //add
+        res = await TagInterface.addTag({TagName})
+    }
+    if(res.Ret!==200) return 
+    //添加/编辑成功
+    ElMessage.success(`${TagId?'编辑':'添加'}成功`)
+    handleCurrentChange(1)
+    isModifyDialogShow.value = false
+}
+function deleteLabel(label){
+    if(label.VideoTotal!==0){
+        ElMessageBox.confirm('该标签已关联视频,删除失败','提示',{confirmButtonText:'知道了',showCancelButton:false,type:'error'})
+        return
+    }
+    ElMessageBox.confirm(
+        '删除后不可恢复,是否确认删除该标签?',
+        '提示',
+        {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning',
+        }
+    ).then(()=>{
+        TagInterface.deleteTag({
+            TagId:label.TagId
+        }).then(res=>{
+            if(res.Ret!==200) return 
+            ElMessage.success('删除成功')
+            handleCurrentChange(1)
+        })
+    }).catch(()=>{}).finally(()=>{})
+}
+</script>
+<template>
+    <!-- 培训管理-标签管理 -->
+    <div class="label-manage-wrap traing-manage">
+        <div class="top-wrap">
+            <el-button type="primary" @click="handleModifyLabel({})">新增标签</el-button>
+            <el-input v-model="tableParams.searchText" clearable 
+                :prefix-icon="Search" placeholder="请输入标签名称" @input="handleCurrentChange(1)" 
+                style="width:240px;"></el-input>
+        </div>
+        <div class="table-wrap">
+            <el-table :data="tableData" border v-loading="tableLoading">
+                <el-table-column v-for="column in tableColumn" :key="column.key" 
+                    :label="column.label" align="center"
+                    :prop="column.key"
+                    :min-width="column.minWidth">
+                </el-table-column>
+                <el-table-column label="操作" align="center">
+                    <template #default="{row}">
+                        <el-link :underline="false" type="primary" style="margin-right: 20px;" @click="handleModifyLabel(row)">编辑</el-link>
+                        <el-link :underline="false" type="danger" @click="deleteLabel(row)" style="color:red;">删除</el-link>
+                    </template>
+                </el-table-column>
+            </el-table>
+            <el-pagination
+                layout="total,prev,pager,next,jumper" 
+                background
+                :current-page="tableParams.currentPage"
+                @current-change="handleCurrentChange"
+                :page-size="tableParams.pageSize" 
+                :total="tableParams.total"
+                style="margin-top: 60px;justify-content: flex-end;">
+            </el-pagination>
+        </div>
+        <!-- 添加标签弹窗 -->
+        <el-dialog
+            :title="currentLabel.TagId?'编辑标签':'添加标签'"
+            v-model="isModifyDialogShow"
+            :close-on-click-modal="false"
+            :modal-append-to-body="false"
+            @close="isModifyDialogShow=false"
+            width="589px"
+            center
+            >
+            <div class="dialog-container">
+                <div class="input-item">
+                <el-input  placeholder="请输入标签名称" v-model.trim="currentLabel.TagName" required ></el-input>
+                </div>
+                <p class="form-hint">注:名称不得超过5个字</p>
+            </div>
+            <div class="foot-container">
+                <el-button @click="isModifyDialogShow=false">取 消</el-button>
+                <el-button type="primary" @click="modifyLabel">确认</el-button>
+            </div>
+        </el-dialog>
+    </div>
+</template>
+
+<style scoped lang="scss">
+@import "./css/manage.scss";
+</style>

+ 44 - 0
src/views/training_manage/mixins/videoMixins.js

@@ -0,0 +1,44 @@
+import {TagInterface,ClassifyInterface} from '@/api/modules/trainingApi'
+export default{
+    data() {
+        return {
+            tagList:[],
+            classifyList:[],
+        }
+    },
+    methods: {
+        //获取分类列表
+        getClassifyList(type=''){
+            ClassifyInterface.getClassifyList({}).then(res=>{
+                if(res.Ret!==200) return 
+                this.classifyList = res.Data&&res.Data.List||[]
+                this.filterNodes(this.classifyList)
+                this.classifyList = this.classifyList.map(item=>{
+                    if(!item.Children){
+                        item.disabled = true
+                    }
+                    return item
+                })
+            })
+        },
+        filterNodes(arr) {
+            arr.length && arr.forEach(item => {
+                item.Children && item.Children.length && this.filterNodes(item.Children)
+                if(item.Children && !item.Children.length) {
+                    delete item.Children
+                }
+            })
+        },
+        //获取标签列表
+        getTagList(keyword=''){
+            TagInterface.getTagList({
+                Keyword:keyword,
+                PageSize:1000,
+                CurrentIndex:1
+            }).then(res=>{
+                if(res.Ret!==200) return 
+                this.tagList = res.Data&&res.Data.List||[]
+            })
+        }
+    }
+}

+ 404 - 0
src/views/training_manage/modifyVideoPage.vue

@@ -0,0 +1,404 @@
+<script setup>
+import MD5 from 'js-md5'
+import {getOSSSign,bannerupload} from '@/api/api.js'
+import {VideoInterface} from '@/api/modules/trainingApi'
+import AddTags from './components/addTags.vue'
+import {useVideo} from './hooks/use-video'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { ref, reactive } from 'vue'
+import { useRoute, useRouter } from "vue-router"
+import _ from 'lodash'
+
+const route = useRoute()
+const router = useRouter()
+const {
+    classifyList,getClassifyList,
+    tagList,getTagList,
+    getDataClassify,
+    previewPop,previewVideo,previewVideoUrl,previewPopTitle,
+    handlePreviewVideo,endingPreview
+} = useVideo()
+getClassifyList()
+
+let form = reactive({
+    Title: '',
+    Introduce: '',
+    ClassifyId: '',
+    TagIds: [],
+    CoverImg: '',
+    VideoUrl: ''
+})
+const formRef = ref(null)
+const rules = reactive({
+    ClassifyId:[{required:true,message:'请选择所属分类'}],
+    Title:[{required:true,message:'请输入视频名称'}],
+    CoverImg:[{required:true,message:'请选择视频封面'}],
+    VideoUrl:[{required:true,message:'请上传视频'}],
+    TagIds:[{required:true,validator:(rule,value,callback)=>{
+        if(!value.length){
+            return callback(new Error("请至少选择一个标签"))
+        }else{
+            return callback()
+        }
+    }}]
+})
+
+let tagIdKey = ref(0)
+let isModifyDialogShow = ref(false)
+let isImageUploading = ref(false)
+//检查图片是否合法
+function handleUploadImg(file){
+    isImageUploading.value = true;
+    //图片格式限制
+    const { type } = file.file;
+    if (!['image/png', 'image/jpeg'].includes(type)) {
+        ElMessage.warning('仅支持png、jpg格式的图片');
+        isImageUploading.value = false;
+        return;
+    }
+    uploadImg(file);
+}
+//上传图片
+function uploadImg(file) {
+    let formData = new FormData();
+    formData.append('file', file.file);
+    bannerupload(formData).then(res => {
+        isImageUploading.value = false;
+        if (res.Ret !== 200) return;
+        form.CoverImg = res.Data.ResourceUrl;
+    });
+}
+let isVideoUploading = ref(false)
+let percentage = ref(0)
+let timeDuration = ref('')
+//检查视频是否合法,并获取视频时长
+async function handleUploadVideo(file) {
+    if(file.file.type!='video/mp4'){
+        ElMessage.warning('上传失败,上传视频格式不正确');
+        return
+    }
+    const duration=await handleGetDuration(file.file);
+    timeDuration.value = `${String(parseInt(duration/60)).padStart(2,'0')}:${String(parseInt(duration%60)).padStart(2,'0')}`;
+    uploadVideo(file.file);
+    isVideoUploading.value = true;
+}
+//获取视频时长的promise
+async function handleGetDuration(file){
+    return new Promise((resolve,reject)=>{
+        const fileUrl=URL.createObjectURL(file);
+        const audioEl=new Audio(fileUrl);
+        audioEl.addEventListener('loadedmetadata',(e)=>{
+            const t=e.composedPath()[0].duration;
+            resolve(t);
+        })
+    })
+}
+//上传视频
+async function uploadVideo(file) {
+    const res = await getOSSSign();
+    if(res.Ret===200){
+        handleUploadToOSS(file,res.Data);
+    }
+}
+//上传到阿里云
+let ALOSSINS=null //阿里云上传实例
+let ALOSSAbortCheckpoint=null //阿里云上传实例中断点
+async function handleUploadToOSS(file,{AccessKeyId,AccessKeySecret,SecurityToken}){
+    ALOSSINS=new OSS({
+        // yourRegion填写Bucket所在地域。以华东1(杭州)为例,Region填写为oss-cn-hangzhou。
+        region: "oss-cn-shanghai",
+        // 从STS服务获取的临时访问密钥(AccessKey ID和AccessKey Secret)。
+        accessKeyId: AccessKeyId,
+        accessKeySecret: AccessKeySecret,
+        // 从STS服务获取的安全令牌(SecurityToken)。
+        stsToken: SecurityToken,
+        // 填写Bucket名称,例如examplebucket。
+        bucket: "hzchart",
+        endpoint:'hzstatic.hzinsights.com',
+        cname:true,
+        timeout:600000000000
+    });
+    // 生成文件名
+    const t=new Date().getTime().toString();
+    const temName=`static/yb/video/${MD5(t)}.${file.type.split('/')[1]}`;
+    const options = {
+        // 获取分片上传进度、断点和返回值。
+        progress: (p, cpt, res) => {
+            ALOSSAbortCheckpoint=cpt;
+            percentage.value=parseInt(p*100);
+        },
+        // 设置并发上传的分片数量。
+        parallel: 10,
+        // 设置分片大小。默认值为1 MB,最小值为100 KB。
+        partSize: 1024 * 1024 * 10, // 10MB
+    };
+    try {
+        const res=await ALOSSINS.multipartUpload(temName,file,{...options});
+        console.log('上传结果',res);
+        if(res.res.status===200){
+            form.VideoUrl='https://hzstatic.hzinsights.com/'+res.name;
+            percentage.value=0;
+            ALOSSAbortCheckpoint=null;
+            isVideoUploading.value = false;
+        }
+    } catch (error) {
+        console.log('上传到阿里云失败',error);
+        if(error.name!=="cancel"){//不是取消上传的则给错误提示
+            ElMessage.warning('上传失败,请刷新重试');
+        }
+        percentage.value=0;
+        ALOSSAbortCheckpoint=null;
+        isVideoUploading.value = false;
+    }
+}
+//预览视频
+function handleOpenPreviewDialog(){
+    if(isVideoUploading.value||!form.VideoUrl) return
+    const data = {
+        Title:form.Title||'暂无标题',
+        VideoUrl:form.VideoUrl
+    }
+    handlePreviewVideo(data)
+
+}
+//删除所选标签
+function removeTag(tag) {
+    const index = form.TagIds.findIndex(i => i.TagId === tag.TagId);
+    index !== -1 && (form.TagIds.splice(index, 1));
+    tagIdKey++;
+}
+//改变所选标签
+function modifyTags(tags) {
+    form.TagIds = _.cloneDeep(tags);
+    isModifyDialogShow.value = false;
+}
+//编辑视频-获取视频信息
+function getVideoDetail() {
+    const { VideoId } = route.query;
+    if (!Number(VideoId)) return;
+    VideoInterface.getVideoDetail({
+        VideoId: Number(VideoId)
+    }).then(res => {
+        if (res.Ret !== 200)
+            return;
+        Object.assign(form,res.Data||{})
+        if (form.Classify) {
+            const { Classify } = form;
+            const classifyArr = getDataClassify(Classify,[],'ClassifyId');
+            form.ClassifyId = classifyArr[classifyArr.length - 1];
+            delete form.Classify;
+        }
+        if (form.Tags) {
+            form.TagIds = form.Tags;
+            delete form.Tags;
+        }
+    });
+}
+getVideoDetail()
+//添加/编辑视频
+async function modifyVideo(type='modify') {
+    try{
+        await formRef.value?.validate()
+    }catch(e){
+        console.log(e)
+        return 
+    }
+    let res = null;
+    let params = { ...form, ...{ TagIds: form.TagIds.map(t => t.TagId) } };
+    if (!form.VideoId) {
+        res = await VideoInterface.addVideo(params);
+    }
+    else {
+        res = await VideoInterface.editVideo(params);
+    }
+    if (res.Ret !== 200)
+        return;
+    type!=='publish'&&ElMessage.success(`${form.VideoId ? '编辑' : '添加'}成功`);
+    type!=='publish'&&changeRoute();
+    !form.VideoId&&(form.VideoId = res.Data?res.Data:'');
+}
+//发布视频
+async function publishVideo(){
+    let res = {}
+    await modifyVideo('publish');
+    if(form.VideoId){
+        res = await VideoInterface.publishVideo({VideoId:Number(form.VideoId),PublishState:1})
+        if(res.Ret!==200) return;
+        ElMessage.success("发布成功");
+    }
+    changeRoute();
+}
+function changeRoute(){
+    if(ALOSSAbortCheckpoint){
+        console.log('终止上传');
+        ALOSSINS.abortMultipartUpload(ALOSSAbortCheckpoint.name,ALOSSAbortCheckpoint.uploadId)
+    }
+    router.push('/trainingVideo');
+}
+</script>
+<template>
+    <!-- 新增编辑视频 -->
+    <div class="modify-video-page-wrap">
+        <el-form :model="form" :rules="rules" ref="formRef">
+            <el-form-item label="所属分类" prop="ClassifyId">
+                <el-cascader placeholder="选择所属分类" 
+                    v-model="form.ClassifyId"
+                    :options="classifyList"
+                    clearable
+                    :show-all-levels="false"
+                    :props="{emitPath:false,
+                            label:'ClassifyName',
+                            value:'ClassifyId',
+                            children:'Children'}">
+                </el-cascader>
+            </el-form-item>
+            <el-form-item label="视频名称" prop="Title">
+                <el-input v-model="form.Title" placeholder="请输入视频名称"></el-input>
+            </el-form-item>
+            <el-form-item label="视频简介" prop="Introduce">
+                <el-input v-model="form.Introduce" 
+                placeholder="请输入视频简介"
+                type="textarea" maxlength="50" show-word-limit :rows="5"></el-input>
+            </el-form-item>
+            <el-form-item label="上传封面" prop="CoverImg">
+                <el-upload action="" accept="image/*" 
+                    :http-request="handleUploadImg" :show-file-list="false" :disabled="isImageUploading">
+                    <el-button type="primary" :loading="isImageUploading">点击上传</el-button>
+                    <span style="color:#999999;margin-left: 5px;" @click.stop>建议尺寸比例3:2,支持png、jpg、gif、jpeg格式</span>
+                </el-upload>
+                <div class="img-box">
+                    <img :src="form.CoverImg" v-if="form.CoverImg">
+                    <span v-else style="color:#999999;line-height: 100px;">请上传封面图</span>
+                </div>
+            </el-form-item>
+            <el-form-item label="上传视频" prop="VideoUrl">
+                <el-upload action="" accept=".mp4" 
+                    :http-request="handleUploadVideo" :show-file-list="false" :disabled="isVideoUploading">
+                    <el-button type="primary" :loading="isVideoUploading">点击上传</el-button>
+                    <span style="color:#999999;margin-left: 5px;" @click.stop>仅支持mp4格式</span>
+                </el-upload>
+                
+                <div class="img-box">
+                    <el-progress type="circle" :percentage="percentage" :width="40" v-if="isVideoUploading"></el-progress>
+                    <span v-if="form.VideoUrl&&!form.VideoId" class="duration">{{timeDuration}}</span>
+                    <img :src="form.CoverImg" v-if="form.VideoUrl" @click="handleOpenPreviewDialog">
+                    <span v-else style="color:#999999;line-height: 100px;">请上传视频</span>
+                </div>
+            </el-form-item>
+            <el-form-item label="视频标签" prop="TagIds">
+                <el-link :underline="false" type="primary" @click="isModifyDialogShow = true">选择标签</el-link>
+                <div class="tag-list" :key="tagIdKey">
+                    <span class="tag-item" v-for="tag in form.TagIds" :key="tag.TagId">
+                        <span>{{tag.TagName}}</span>
+                        <span @click.stop="removeTag(tag)"><i class="el-icon-close"></i></span>
+                    </span>
+                </div>
+            </el-form-item>
+        </el-form>
+        <div class="btn-box">
+            <el-button type="primary" plain @click="changeRoute">取消</el-button>
+            <el-button type="primary" @click="modifyVideo" :loading="isImageUploading||isVideoUploading">保存</el-button>
+            <el-button type="primary" @click="publishVideo" :loading="isImageUploading||isVideoUploading">发布</el-button>
+        </div>
+        <!-- 选择标签弹窗 -->
+        <AddTags 
+            :isModifyDialogShow="isModifyDialogShow"
+            :Tags="form.TagIds"
+            :tagList="tagList"
+            :getTagList="getTagList"
+            @modify="modifyTags"
+            @close="isModifyDialogShow=false"
+        />
+        <!-- 预览视频弹窗 -->
+        <el-dialog
+            v-model="previewPop"
+            :modal-append-to-body='false'
+            width="60vw"
+            :title="previewPopTitle"
+            @close="endingPreview"
+        >
+         <video style="width: 100%;height: 100%;max-height: 70vh;outline: none;" 
+         controls :src="previewVideoUrl" autoplay ref="previewVideo">
+            您的浏览器暂不支持,请更换浏览器
+         </video>   
+        </el-dialog>
+    </div>
+</template>
+
+<style scoped lang="scss">
+.modify-video-page-wrap{
+    box-sizing: border-box;
+    padding:40px;
+    background-color: #fff;
+    border-radius: 4px;
+    .el-form{
+        .el-input,.el-select,.el-textarea{
+            width:500px;
+        }
+        .img-box{
+            /* background-color: #D9D9D9; */
+            border:1px dashed #d9d9d9;
+            width:150px;
+            height:100px;
+            text-align: center;
+            margin-top: 10px;
+            position: relative;
+            line-height: normal;
+            img{
+                width:100%;
+                height:100%;
+            }
+            .duration{
+                position:absolute;
+                right:0;
+                bottom:0;
+                background-color: black;
+                color:#fff;
+                padding:4px;
+            }
+            .el-progress{
+                position: absolute;
+                left:50%;
+                top:50%;
+                transform: translate(-50%,-50%);
+                background-color: white;
+                border-radius: 50%;
+            }
+        }
+        .tag-list{
+            display: flex;
+            gap:10px;
+            line-height:0;
+            .tag-item{
+                cursor: pointer;
+                text-align: center;
+                box-sizing: border-box;
+                padding:12px;
+                min-width:78px;
+                color: #409EFF;
+                background-color: #EAF3FE;
+                border:1px solid #409EFF;
+                border-radius: 2px;
+            }
+        }
+        :deep(.el-textarea__inner){
+            resize: none;
+        }
+        .el-form-item{
+            :deep(.el-form-item__content){
+                display: flex;
+                flex-direction: column;
+                align-items:flex-start;
+            }
+        }
+    }
+    .btn-box{
+        text-align: center;
+        .el-button{
+            margin-right: 50px;
+            width:120px;
+            text-align: center;
+        }
+    }
+}
+</style>

+ 246 - 0
src/views/training_manage/videoManage.vue

@@ -0,0 +1,246 @@
+<script setup>
+import {videoTableColumn} from './config/tableColumn'
+import {VideoInterface} from '@/api/modules/trainingApi'
+import {useVideo} from './hooks/use-video'
+import { ref,reactive } from 'vue'
+import { useRouter } from "vue-router"
+import { Search } from '@element-plus/icons-vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+
+const router = useRouter()
+const {
+    classifyList,getClassifyList,
+    tagList,getTagList,
+    getDataClassify,
+    previewPop,previewVideo,previewVideoUrl,previewPopTitle,
+    handlePreviewVideo,endingPreview
+} = useVideo()
+getClassifyList()
+getTagList()
+
+/* search options */
+let searchParams = reactive({
+    searchText:'',
+    datePick:[],
+    ClassifyId:'',
+    TagIds:[],
+    PublishState:'',
+})
+
+let tableParams = reactive({
+    currentPage: 1,
+    pageSize: 10,
+    total: 0,
+})
+let tableData = ref([])
+const tableColumn = videoTableColumn
+function getTableData(){
+    VideoInterface.getVideoList({
+        PageSize:tableParams.pageSize,
+        CurrentIndex:tableParams.currentPage,
+        Keyword:searchParams.searchText,
+        StartTime:searchParams.datePick?searchParams.datePick[0]:'',
+        EndTime:searchParams.datePick?searchParams.datePick[1]:'',
+        ClassifyId:Number(searchParams.ClassifyId),
+        TagIds:searchParams.TagIds.join(','),
+        PublishState:Number(searchParams.PublishState)
+    }).then(res=>{
+        if(res.Ret!==200) return 
+        if(!res.Data){
+            tableData.value = []
+            tableParams.total = 0
+            return
+        } 
+        tableData.value = res.Data.List||[]
+        tableParams.total = res.Data.Paging.Totals
+    })
+}
+getTableData()
+function handleCurrentChange(page){
+    tableParams.currentPage = page
+    getTableData()
+}
+
+function handleModifyVideo(VideoId){
+    router.push({path:'/modifyVideo',query:{VideoId}})
+}
+
+function deleteVideo(data){
+    const hint = data.PublishState===0?'删除后不可恢复,是否确认删除?':'该视频已发布,删除后取消发布并删除,是否确认删除?'
+    ElMessageBox.confirm(
+            hint,'提示',
+            {
+                confirmButtonText: '确定',
+                cancelButtonText: '取消',
+                type: 'warning',  
+            }
+    ).then(()=>{
+        VideoInterface.deleteVideo({
+            VideoId:data.VideoId,
+        }).then(res=>{
+            if(res.Ret!==200) return 
+            ElMessage.success("删除成功")
+            getTableData()
+        })
+    })
+}
+
+function publishVideo(data){
+    const hint = data.PublishState===0?'是否确认发布':'是否取消发布'
+    ElMessageBox.confirm(
+        hint,'提示',
+        {
+            confirmButtonText: '确定',
+            cancelButtonText: '取消',
+            type: 'warning',  
+        }
+    ).then(()=>{
+        VideoInterface.publishVideo({
+            VideoId:data.VideoId,
+            PublishState:data.PublishState===0?1:0
+        }).then(res=>{
+            if(res.Ret!==200) return 
+            ElMessage.success(`${data.PublishState?'取消发布':'发布'}成功`)
+            getTableData()
+        })
+    })
+}
+</script>
+<template>
+    <div class="video-manage-wrap traing-manage">
+        <div class="top-wrap">
+            <div class="select-box">
+                <el-date-picker
+                    v-model="searchParams.datePick"
+                    type="daterange"
+                    value-format="YYYY-MM-DD"
+                    range-separator="至"
+                    start-placeholder="开始日期"
+                    end-placeholder="结束日期"
+                    clearable
+                    @change="handleCurrentChange(1)">
+                </el-date-picker>
+                <el-cascader placeholder="选择分类" 
+                    v-model="searchParams.ClassifyId"
+                    :options="classifyList"
+                    clearable
+                    :show-all-levels="false"
+                    :props="{emitPath:false,
+                            checkStrictly:true,
+                            label:'ClassifyName',
+                            value:'ClassifyId',
+                            children:'Children'}"
+                    @change="handleCurrentChange(1)">
+                </el-cascader>
+                <el-select placeholder="选择标签" 
+                    v-model="searchParams.TagIds" multiple clearable
+                    @change="handleCurrentChange(1)">
+                    <el-option
+                        v-for="item in tagList"
+                        :key="item.TagId"
+                        :label="item.TagName"
+                        :value="item.TagId">
+                    </el-option>
+                </el-select>
+                <el-select placeholder="选择状态" 
+                    v-model="searchParams.PublishState" clearable
+                    @change="handleCurrentChange(1)">
+                    <el-option label="未发布" :value="1"/>
+                    <el-option label="已发布" :value="2"/>
+                </el-select>
+            </div>
+            <el-input v-model="searchParams.searchText" clearable 
+                :prefix-icon="Search" placeholder="请输入视频名称" @input="handleCurrentChange(1)" 
+                style="width:240px;"></el-input>
+        </div>
+        <div class="table-wrap">
+            <el-button type="primary" @click="handleModifyVideo(0)">新增视频</el-button>
+            <el-table border :data="tableData">
+                <el-table-column v-for="column in tableColumn" :key="column.key" 
+                    :label="column.label" align="center"
+                    :prop="column.key"
+                    :width="column.Width"
+                    :min-width="column.minWidth">
+                    <template #default="{row}">
+                        <!-- 视频封面 -->
+                        <div class="img-box" v-if="column.key==='CoverImg'">
+                            <img :src="row.CoverImg" @click="handlePreviewVideo(row)">
+                        </div>
+                        <!-- 分类 -->
+                        <span v-else-if="column.key==='Classify'">
+                            {{getDataClassify(row.Classify).join('/')}}
+                        </span>
+                        <!-- 标签 -->
+                        <div class="label-box" v-else-if="column.key==='Tags'">
+                            <span class="label-item" v-for="tag in row.Tags" :key="tag.TagId">{{tag.TagName}}</span>
+                        </div>
+                        <!-- 发布状态 -->
+                        <span v-else-if="column.key==='PublishState'">{{row.PublishState===0?'未发布':'已发布'}}</span>
+                        <span v-else>{{row[column.key]}}</span>
+                    </template>
+                </el-table-column>
+                <el-table-column label="操作" align="center" width="150">
+                    <template #default="{row}">
+                        <el-link :underline="false" type="primary" style="margin-right: 20px;" @click="handleModifyVideo(row.VideoId)" v-show="row.PublishState===0">编辑</el-link>
+                        <el-link :underline="false" type="primary" style="margin-right: 20px;" @click="publishVideo(row)">{{row.PublishState===0?'发布':'取消发布'}}</el-link>
+                        <el-link :underline="false" type="danger" @click="deleteVideo(row)">删除</el-link>
+                    </template>
+                </el-table-column>
+            </el-table>
+            <el-pagination
+                layout="total,prev,pager,next,jumper" 
+                background
+                :current-page="tableParams.currentPage"
+                @current-change="handleCurrentChange"
+                :page-size="tableParams.pageSize" 
+                :total="tableParams.total"
+                style="margin-top: 60px;justify-content: flex-end;">
+            </el-pagination>
+        </div>
+        <!-- 预览视频弹窗 -->
+        <el-dialog
+            v-model="previewPop"
+            :modal-append-to-body='false'
+            width="60vw"
+            :title="previewPopTitle"
+            @close="endingPreview"
+        >
+            <video style="width: 100%;height: 100%;max-height: 70vh;outline: none;" 
+            controls :src="previewVideoUrl" autoplay ref="previewVideo">
+            您的浏览器暂不支持,请更换浏览器
+            </video>
+        </el-dialog>
+    </div>
+</template>
+
+<style scoped lang="scss">
+@import "./css/manage.scss";
+.video-manage-wrap{
+    .table-wrap{
+        .el-table{
+            margin-top:20px;
+            .img-box{
+                text-align: center;
+                img{
+                    width:150px;
+                    height:100px;
+                }
+            }
+            .label-box{
+                display: flex;
+                justify-content: center;
+                gap:8px;
+                .label-item{
+                    padding:6px 12px;
+                    border: 1px solid #409EFF;
+                    color: #409EFF;
+                    background-color: #EAF3FE;
+                    box-sizing: border-box;
+                    border-radius: 4px;
+                }
+            }
+        }
+    }
+}
+</style>