浏览代码

分类管理、视频管理

chenlei 3 月之前
父节点
当前提交
eb2b6a966e

+ 2 - 0
index.html

@@ -9,5 +9,7 @@
   <body>
     <div id="app"></div>
     <script type="module" src="/src/main.js"></script>
+    	<!-- oss SDK -->
+    <script type="text/javascript" src="https://gosspublic.alicdn.com/aliyun-oss-sdk-6.16.0.min.js"></script>
   </body>
 </html>

+ 1 - 0
package.json

@@ -17,6 +17,7 @@
     "dagre": "^0.8.5",
     "element-plus": "^2.7.1",
     "highcharts": "^11.4.1",
+    "js-md5": "^0.8.3",
     "lodash": "^4.17.21",
     "moment": "^2.30.1",
     "normalize.css": "^8.0.1",

+ 178 - 0
src/api/modules/trainingApi.js

@@ -0,0 +1,178 @@
+import {get,post} from '@/api/index'
+
+/* 培训管理模块 */
+
+
+//标签管理
+export const TagInterface = {
+    /**
+     * 获取标签列表
+     * @param {Object} params 
+     * @param {Number} params.PageSize 
+     * @param {Number} params.CurrentIndex
+     * @param {String} params.Keyword
+     * @returns 
+     */
+    getTagList:(params)=>{
+        return get('/eta_training_video/tag/page_list',params)
+    },
+    /**
+     * 新增标签
+     * @param {Object} params 
+     * @param {String} params.TagName
+     * @returns 
+     */
+    addTag:(params)=>{
+        return post('/eta_training_video/tag/add',params)
+    },
+    /**
+     * 编辑标签
+     * @param {Object} params 
+     * @param {Number} params.TagId
+     * @param {String} params.TagName
+     * @returns 
+     */
+    editTag:(params)=>{
+        return post('/eta_training_video/tag/edit',params)
+    },
+    /**
+     * 删除标签
+     * @param {Object} params 
+     * @param {Number} params.TagId
+     * @returns 
+     */
+    deleteTag:(params)=>{
+        return post('/eta_training_video/tag/remove',params)
+    }
+}
+
+//分类管理
+export const ClassifyInterface = {
+    /**
+     * 获取分类列表
+     * @param {Object} params 
+     * @param {Number} params.Keyword 
+     * @returns 
+     */
+     getClassifyList:(params)=>{
+        return get('/eta_training_video/classify/tree',params)
+    },
+    /**
+     * 新增分类
+     * @param {Object} params 
+     * @param {Number} params.ParentId
+     * @param {String} params.ClassifyName
+     * @returns 
+     */
+    addClassify:(params)=>{
+        return post('/eta_training_video/classify/add',params)
+    },
+    /**
+     * 编辑分类
+     * @param {Object} params 
+     * @param {Number} params.ParentId
+     * @param {Number} params.ClassifyId
+     * @param {String} params.ClassifyName
+     * @returns 
+     */
+    editClassify:(params)=>{
+        return post('/eta_training_video/classify/edit',params)
+    },
+    /**
+     * 删除分类
+     * @param {Object} params 
+     * @param {Number} params.ClassifyId
+     * @returns 
+     */
+    deleteClassify:(params)=>{
+        return post('/eta_training_video/classify/remove',params)
+    }
+}
+
+//视频管理
+export const VideoInterface = {
+    /**
+     * 获取视频列表
+     * @param {Object} params 
+     * @param {Number} params.PageSize
+     * @param {Number} params.CurrentIndex 
+     * @param {String} params.Keyword
+     * @param {String} params.StartTime
+     * @param {String} params.EndTime
+     * @param {Number} params.ClassifyId
+     * @param {String} params.TagIds 标签IDs, 英文逗号拼接
+     * @param {Number} params.PublishState 发布状态:1-未发布;2-已发布
+     * @returns 
+     */
+    getVideoList:(params)=>{
+        return get('/eta_training_video/page_list',params)
+    },
+    /**
+     * 新增视频
+     * @param {Object} params 
+     * @param {String} params.Title
+     * @param {String} params.Introduce
+     * @param {String} params.CoverImg
+     * @param {String} params.VideoUrl
+     * @param {Number} params.ClassifyId
+     * @param {Array} params.TagIds
+     * @returns 
+     */
+    addVideo:(params)=>{
+        return post('/eta_training_video/add',params)
+    },
+    /**
+     * 编辑视频
+     * @param {Object} params 
+     * @param {Number} params.VideoId
+     * 其他同上
+     * @returns 
+     */
+    editVideo:(params)=>{
+        return post('/eta_training_video/edit',params)
+    },
+    /**
+     * 发布/取消发布视频
+     * @param {Object} params
+     * @param {Number} params.VideoId
+     * @param {Number} params.PublishState 发布状态:0-取消发布;1-发布
+     * @returns 
+     */
+    publishVideo:(params)=>{
+        return post('/eta_training_video/publish',params)
+    },
+    /**
+     * 删除视频
+     * @param {Object} params
+     * @param {Number} params.VideoId
+     * @returns 
+     */
+    deleteVideo:(params)=>{
+        return post('/eta_training_video/remove',params)
+    },
+    /**
+     * 获取视频详情
+     * @param {Object} params 
+     * @param {Number} params.VideoId
+     * @returns 
+     */
+    getVideoDetail:(params)=>{
+        return get('/eta_training_video/detail',params)
+    },
+    /**
+     * 上传banner图
+     * @param {Object} params 
+     * @returns 
+     */
+    bannerupload:(params)=>{
+        return post('/banner/upload',params)
+    },
+    /**
+     * 上传阿里云 oss获取临时票据
+     * @param {Object} params 
+     * @returns 
+     */
+    getOSSSign:()=>{
+        return get('/resource/oss/get_sts_token',{})
+    },
+}

+ 0 - 59
src/router/modules/customer.js

@@ -90,63 +90,4 @@ export default[
       }
     ]
   },
-  {
-    path:'/etaTrial',
-    name:'etaTrial',
-    component:LayoutIndex,
-    meta:{
-      title:'ETA试用管理'
-    },
-    children:[
-      {
-        path: "etaTrialList",
-        component: () => import("@/views/etaTrial/etaTrialList.vue"),
-        name: "etaTrialList",
-        hidden: false,
-        meta: {
-          title:'ETA试用列表'
-        },
-      },
-      {
-        path: "etaApprovalList",
-        component: () => import("@/views/etaTrial/etaTrialList.vue"),
-        name: "etaApprovalList",
-        hidden: false,
-        meta: {
-          pathFrom: "etaTrialList",
-          pathName: "ETA试用",
-          title:'客户列表'
-        },
-      },
-      {
-        path: "etaAddApproval",
-        component: () => import("@/views/etaTrial/addApproval.vue"),
-        name: "etaAddApproval",
-        hidden: false,
-        meta: {
-          pathFrom: "etaTrialList",
-          pathName: "ETA试用",
-          title:'新增申请'
-        },
-      },
-    ]
-  },
-  {
-    path:'/etaMenuConfig',
-    name:'etaMenuConfig',
-    component:LayoutIndex,
-    meta:{
-      title:'ETA菜单配置'
-    },
-    children:[
-      {
-        path:'etaMenuList',
-        name:'etaMenuList',
-        component:() => import('@/views/etaMenu_manage/etaMenuConfig.vue'),
-        meta:{
-          title:'ETA菜单配置'
-        },
-      }
-    ]
-  },
 ]

+ 22 - 0
src/router/modules/etaMenu.js

@@ -0,0 +1,22 @@
+import LayoutIndex from "@/layout/Index.vue";
+
+export default[
+  {
+    path:'/etaMenuConfig',
+    name:'EtaMenuConfig',
+    component:LayoutIndex,
+    meta:{
+      title:'ETA菜单配置'
+    },
+    children:[
+      {
+        path:'etaMenuList',
+        name:'etaMenuList',
+        component:() => import('@/views/etaMenu_manage/etaMenuConfig.vue'),
+        meta:{
+          title:'ETA菜单配置'
+        },
+      }
+    ]
+  }
+]

+ 45 - 0
src/router/modules/etaTrial.js

@@ -0,0 +1,45 @@
+import LayoutIndex from "@/layout/Index.vue";
+
+export default[
+  {
+    path:'/etaTrial',
+    name:'EtaTrial',
+    component:LayoutIndex,
+    meta:{
+      title:'ETA试用管理'
+    },
+    children:[
+      {
+        path: "etaTrialList",
+        component: () => import("@/views/etaTrial/etaTrialList.vue"),
+        name: "etaTrialList",
+        hidden: false,
+        meta: {
+          title:'ETA试用列表'
+        },
+      },
+      {
+        path: "etaApprovalList",
+        component: () => import("@/views/etaTrial/etaTrialList.vue"),
+        name: "etaApprovalList",
+        hidden: false,
+        meta: {
+          pathFrom: "etaTrialList",
+          pathName: "ETA试用",
+          title:'客户列表'
+        },
+      },
+      {
+        path: "etaAddApproval",
+        component: () => import("@/views/etaTrial/addApproval.vue"),
+        name: "etaAddApproval",
+        hidden: false,
+        meta: {
+          pathFrom: "etaTrialList",
+          pathName: "ETA试用",
+          title:'新增申请'
+        },
+      },
+    ]
+  },
+]

+ 46 - 0
src/router/modules/training.js

@@ -0,0 +1,46 @@
+import LayoutIndex from "@/layout/Index.vue";
+
+export default[
+  {
+    path:'/training',
+    name:'Training',
+    component:LayoutIndex,
+    meta:{
+      title:'培训管理'
+    },
+    children:[
+      {
+        path:'trainingVideo',
+        name:'TrainingVideo',
+        component:()=>import('@/views/training/videoManage.vue'),
+        meta:{
+          title:'视频管理'
+        },
+      },
+      {
+        path:'trainingLabel',
+        name:'TrainingLabel',
+        component:()=>import('@/views/training/labelManage.vue'),
+        meta:{
+          title:'标签管理'
+        },
+      },
+      {
+        path:'trainingClassify',
+        name:'TrainingClassify',
+        component:()=>import('@/views/training/classifyManage.vue'),
+        meta:{
+          title:'分类管理'
+        },
+      },
+      {
+        path:'modifyVideo',
+        name:'ModifyVideo',
+        component:()=>import('@/views/training/modifyVideoPage.vue'),
+        meta:{
+          title:'编辑视频'
+        },
+      },
+    ]
+  }
+]

+ 72 - 57
src/views/etaMenu_manage/components/ChoosedIconDialog.vue

@@ -2,7 +2,7 @@
     <div class="choosed-icon-wrap">
         <t-dialog
             :visible.sync="isShowIconDialog"
-            title="选择图标"
+            header="选择图标"
             @close="$emit('close')"
             width="520px"
             center
@@ -36,63 +36,78 @@
     </div>
 </template>
 
-<script>
-import {menuConfigInterface} from '@/api/modules/etaMenuApi';
-export default {
-    props:{
-        isShowIconDialog:{
-            type:Boolean,
-            default:false
-        }
-    },
-    data() {
-        return {
-            iconList:[],
-            choosedIcon:''
-        };
-    },
-    watch:{
-        isShowIconDialog(newVal){
-            if(newVal){
-                this.getIconList()
-            }
-        }
-    },
-    methods: {
-        getIconList(){
-            menuConfigInterface.getMenuIconList().then(res=>{
-                if(res.Ret!==200) return 
-                this.iconList = res.Data||[]
-            })
-        },
-        async handleUploadImg(e){
-            if(!['image/png','image/jpeg'].includes(e.file.type)){
-                this.$message.warning('仅支持png、jpg格式的图片')
-                return
-            }
-            if(e.file.size>50*1024){
-                this.$message.warning('图标文件大小不能超过50kb')
-                return 
-            }
-            let form = new FormData()
-            form.append("file", e.file)
-            const res = await bannerupload.bannerupload(form)
-            if(res.Ret===200){
-                await menuConfigInterface.addMenuIcon({
-                    IconPath:res.Data.ResourceUrl
-                })
-                this.getIconList()
-            }
-        },
-        saveIcon(){
-            if(!this.choosedIcon){
-                this.$message.warning('请选择图标')
-                return
-            }
-            this.$emit('save',this.choosedIcon)
-        }
-    },
+<script setup>
+import { ref, watch, onMounted } from 'vue';
+import { menuConfigInterface } from '@/api/modules/etaMenuApi';
+
+// 定义响应式数据
+const props = defineProps({
+  isShowIconDialog: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const iconList = ref([]);
+const choosedIcon = ref('');
+
+// 初始化获取图标列表的方法
+const getIconList = async () => {
+  try {
+    const res = await menuConfigInterface.getMenuIconList();
+    if (res.Ret === 200) {
+      iconList.value = res.Data || [];
+    }
+  } catch (error) {
+    console.error('获取图标列表失败', error);
+  }
+};
+
+// 监听 isShowIconDialog 属性的变化
+watch(() => props.isShowIconDialog, (newVal) => {
+  if (newVal) {
+    getIconList();
+  }
+});
+
+// 处理图片上传的方法
+const handleUploadImg = async (e) => {
+  if (!['image/png', 'image/jpeg'].includes(e.file.type)) {
+    MessagePlugin.warning('仅支持png、jpg格式的图片');
+    return;
+  }
+  if (e.file.size > 50 * 1024) {
+    MessagePlugin.warning('图标文件大小不能超过50kb');
+    return;
+  }
+
+  const form = new FormData();
+  form.append('file', e.file);
+
+  try {
+    const res = await bannerupload.bannerupload(form);
+    if (res.Ret === 200) {
+      await menuConfigInterface.addMenuIcon({
+        IconPath: res.Data.ResourceUrl,
+      });
+      getIconList();
+    }
+  } catch (error) {
+    console.error('上传图标失败', error);
+  }
 };
+
+// 保存图标的方法
+const saveIcon = () => {
+  if (!choosedIcon.value) {
+    MessagePlugin.warning('请选择图标');
+    return;
+  }
+  emit('save', choosedIcon.value);
+};
+
+// 定义 emit 函数
+const emit = defineEmits(['save']);
 </script>
 
 <style scoped lang="scss">

+ 2 - 2
src/views/etaMenu_manage/components/ModifyMenuDialog.vue

@@ -4,9 +4,9 @@
             :visible.sync="isShowMenuDialog"
             :close-on-click-modal="false"
             :modal-append-to-body='false'
-            :title="form.MenuId?'编辑菜单':'添加菜单'"
+            :header="form.MenuId?'编辑菜单':'添加菜单'"
             @close="$emit('close')"
-            width="820px"
+            width="720px"
             center
             :footer="false"
         >

+ 1 - 1
src/views/etaTrial/compontents/addApplyHintDialog.vue

@@ -6,7 +6,7 @@
       :visible.sync="isAddApplyHintShow"
       :close-on-click-modal="false"
       :modal-append-to-body="false"
-      title="提示"
+      header="提示"
       @close="handleApprove(false)"
       width="889px"
       v-dialogDrag

+ 1 - 1
src/views/etaTrial/compontents/applyApprovalDialog.vue

@@ -5,7 +5,7 @@
       :visible.sync="isApplyApprovalDialogShow"
       :close-on-click-modal="false"
       :modal-append-to-body="false"
-      :title="`申请${applyInfo.applyType===0?'启用':'账号'}审批`"
+      :header="`申请${applyInfo.applyType===0?'启用':'账号'}审批`"
       @close="closeDialog"
       width="889px"
       v-dialogDrag

+ 1 - 1
src/views/etaTrial/compontents/move.vue

@@ -5,7 +5,7 @@
       :visible.sync="isMoveShow"
       :close-on-click-modal="false"
       :modal-append-to-body="false"
-      :title="title"
+      :header="title"
       @close="handleMove(false)"
       width="530px"
       v-dialogDrag

+ 1 - 1
src/views/etaTrial/etaTrialList.vue

@@ -127,7 +127,7 @@
       :visible="showDetailDialogShow"
       :close-on-click-modal="false"
       :modal-append-to-body="false"
-      :title="`${applyInfo.applyData[0].UserName}——账号密码`"
+      :header="`${applyInfo.applyData[0].UserName}——账号密码`"
       width="889px"
       draggable
       center

+ 162 - 0
src/views/training/classifyManage.vue

@@ -0,0 +1,162 @@
+<template>
+    <div class="classify-manage-wrap traing-manage">
+        <div class="top-wrap">
+            <t-button theme="primary" @click="handleModifyClassify({})">添加分类</t-button>
+            <t-input v-model="searchText" clearable placeholder="请输入分类名称" @input="getTableData" style="width:240px;">
+                <template #prefixIcon>
+                    <SearchIcon/>
+                </template>
+            </t-input>
+        </div>
+        <div class="table-wrap">
+            <t-enhanced-table :data="tableData" :columns="classifyColumn" rowKey="ClassifyId" :tree="{ childrenKey: 'Children' }">
+                <template #ClassifyName="{ row }">
+                    <span>{{row.ParentId?'':row.ClassifyName}}</span>
+                </template>
+                <template #ParentId="{ row }">
+                    <span>{{row.ParentId ? row.ClassifyName:''}}</span>
+                </template>
+                <template #opt="{ row }">
+                    <t-button variant="text" theme="primary" @click="handleModifyClassify(row)">编辑</t-button>
+                    <t-button variant="text" theme="primary" @click="deleteClassify(row)">删除</t-button>
+                </template>
+            </t-enhanced-table>
+        </div>
+        <!-- 添加分类弹窗 -->
+        <t-dialog :header="currentClassify.ClassifyId?'编辑分类':'添加分类'" :visible.sync="isModifyDialogShow" @close="isModifyDialogShow=false" width="589px" :footer="false" center>
+            <div class="dialog-container">
+                <t-form :model="currentClassify" :rules="rules" ref="formData" label-position="top">
+                    <t-form-item label="上级分类" prop="ParentId">
+                        <t-select v-model="currentClassify.ParentId" placeholder="请选择上级分类" style="width:100%;">
+                            <t-option v-for="item in currentClassify.ClassifyId
+                                ?(currentClassify.ParentId<=0?defaultList:optionList)
+                                :[...defaultList,...optionList]" 
+                            :key="item.ClassifyId"
+                            :label="item.ClassifyName"
+                            :value="item.ClassifyId"/>
+                        </t-select>
+                    </t-form-item>
+                    <t-form-item label="分类名称" prop="ClassifyName">
+                        <t-input v-model="currentClassify.ClassifyName" placeholder="请输入分类名称" style="width:100%;"></t-input>
+                    </t-form-item>
+                </t-form>
+            </div>
+            <div class="foot-container">
+                <t-button @click="isModifyDialogShow=false">取 消</t-button>
+                <t-button theme="primary" @click="modifyClassify">确认</t-button>
+            </div>
+        </t-dialog>
+    </div>
+</template>
+<script setup>
+import { ref, onMounted, watch, computed } from 'vue';
+import { ClassifyInterface } from '@/api/modules/trainingApi';
+import { classifyTableColumn } from './config/tableColumn';
+import { SearchIcon } from 'tdesign-icons-vue-next';
+import _ from 'lodash';
+
+const classifyColumn = classifyTableColumn
+// 定义响应式数据
+const searchText = ref('');
+const tableData = ref([]);
+const tableLoading = ref(false);
+const currentClassify = ref({});
+const defaultList = ref([{ ClassifyId: -1, ClassifyName: '无' }]);
+const isModifyDialogShow = ref(false);
+const formData = ref(null);
+const rules = {
+  ParentId: [{ required: true, message: '请选择上级分类' }],
+  ClassifyName: [{ required: true, message: '请输入分类名称' }]
+}
+
+// 计算属性
+const optionList = computed(() => {
+  return tableData.value.map(i => ({
+    ClassifyId: i.ClassifyId,
+    ClassifyName: i.ClassifyName
+  }));
+});
+
+// 监听isModifyDialogShow
+watch(isModifyDialogShow, (newVal) => {
+  if (newVal) {
+    nextTick(() => {
+      if (formData.value) {
+        formData.value.clearValidate();
+      }
+    });
+  }
+});
+
+// 方法
+const handleModifyClassify = (data) => {
+  currentClassify.value = _.cloneDeep(data);
+  if (data.ParentId === 0) {
+    currentClassify.value.ParentId = -1;
+  }
+  isModifyDialogShow.value = true;
+};
+
+const modifyClassify = async () => {
+  await formData.value.validate();
+  let res = null;
+  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;
+  MessagePlugin.success(`${currentClassify.value.ClassifyId ? '编辑' : '添加'}成功`);
+  getTableData();
+  isModifyDialogShow.value = false;
+};
+
+const deleteClassify = async (data) => {
+  if (data.Children && data.Children.length) {
+    await  $confirmDialog({
+        body: '该分类下已关联内容,不可删除!',
+        confirmBtn:{default:'知道了',theme:'primary'},
+        cancelBtn: null
+    })
+  } else {
+    await  $confirmDialog({
+        body: '删除后不可恢复,是否确认删除?',
+        confirmBtn:{default:'确认',theme:'primary'},
+    })
+    ClassifyInterface.deleteClassify({
+        ClassifyId: data.ClassifyId
+    }).then(res => {
+    if (res.Ret !== 200) return;
+    MessagePlugin.success('删除成功');
+        getTableData();
+    });
+  }
+};
+
+const getTableData = async () => {
+  ClassifyInterface.getClassifyList({
+    Keyword: searchText.value
+  }).then(res => {
+    if (res.Ret !== 200) return;
+    tableData.value = res.Data && res.Data.List || [];
+    console.log(tableData.value);
+    
+  });
+};
+
+// 组件挂载时调用
+onMounted(() => {
+  getTableData();
+});
+</script>
+
+<style scoped lang="scss">
+@import "./css/manage.scss";
+</style>

+ 190 - 0
src/views/training/components/addTags.vue

@@ -0,0 +1,190 @@
+<template>
+    <t-dialog
+        header="选择标签"
+        :visible.sync="isModifyDialogShow"
+        @close="$emit('close')"
+        width="589px"
+        center
+        :footer="false"
+        class="add-Tags-wrap"
+    >
+        <div class="dialog-container">
+            <div class="search-box">
+                <t-input  placeholder="请输入标签名称" v-model.trim="searchText" clearable @input="getTagList(searchText)">
+                    <template #prefixIcon>
+                        <SearchIcon/>
+                    </template>
+                </t-input>
+                <t-button variant="text" theme="primary" @click="getTagList(searchText)">搜索</t-button>
+            </div>
+            <div class="tag-list-box">
+                <t-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 }}
+                </t-tag>
+            </div>
+            <div class="add-tag-box">
+                <t-input  placeholder="请输入标签名称" v-model.trim="addText"></t-input>
+                <t-button variant="text" theme="primary" @click="addTag">添加</t-button>
+                <t-button variant="text" theme="primary" @click="toTagPage">标签管理</t-button>
+            </div>
+            <p style="color:#999999;font-size: 12px;">注:名称不得超过5个字</p>
+        </div>
+        <div class="foot-container">
+            <t-button @click="$emit('close')">取 消</t-button>
+            <t-button theme="primary" @click="modifyTags">确认</t-button>
+        </div>
+    </t-dialog>
+</template>
+
+<script setup>
+import { ref, watch } from 'vue';
+import { TagInterface } from '@/api/modules/trainingApi';
+import { SearchIcon } from 'tdesign-icons-vue-next';
+import _ from 'lodash';
+
+// 定义 emit 函数
+const emit = defineEmits(['modify', 'close']);
+
+// 定义props
+const props = defineProps({
+  isModifyDialogShow: {
+    type: Boolean,
+    default: false,
+  },
+  Tags: {
+    type: Array,
+    default: [],
+  },
+  getTagList: {
+    type: Function,
+    default: null,
+  },
+  tagList: {
+    type: Array,
+    default: [],
+  },
+});
+
+// 定义组件
+const components = {
+  SearchIcon,
+};
+
+// 定义响应式数据
+const searchText = ref('');
+const addText = ref('');
+const choosedTags = ref([]);
+
+// 监听isModifyDialogShow的变化
+watch(
+  () => props.isModifyDialogShow,
+  (newVal) => {
+    if (newVal) {
+      searchText.value = '';
+      addText.value = '';
+      choosedTags.value = _.cloneDeep(props.Tags);
+      if (props.getTagList) {
+        props.getTagList();
+      }
+    }
+  }
+);
+
+// 定义方法
+const 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) {
+      MessagePlugin.warning("最多选择3个标签");
+      return;
+    }
+    choosedTags.value.push(tag);
+  }
+};
+
+const addTag = () => {
+  if (!addText.value) {
+    MessagePlugin.warning("请输入标签名称");
+    return;
+  }
+  if (addText.value.length > 5) {
+    MessagePlugin.warning("标签名称过长,请重新编辑"); 
+    return;
+  }
+  TagInterface.addTag({ TagName: addText.value }).then((res) => {
+    if (res.Ret !== 200) return;
+    if (props.getTagList) {
+      props.getTagList();
+    }
+    addText.value = '';
+  });
+};
+
+const modifyTags = () => {
+  emit('modify', choosedTags.value);
+};
+
+const toTagPage = () => {
+  window.open('/trainingLabel');
+};
+</script>
+
+<style scoped lang="scss">
+.add-Tags-wrap{
+    .t-dialog{
+        .dialog-container{
+            .search-box {
+                display: flex;
+                align-items: center;
+            }
+            .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;
+                display: flex;
+                gap: 10px;
+                align-items: center;
+            }
+        }
+        .foot-container{
+            text-align: center;
+            padding:20px 0;
+            display: flex;
+            gap: 10px;
+            justify-content: flex-end;
+        }
+    }
+}
+</style>

+ 60 - 0
src/views/training/config/tableColumn.js

@@ -0,0 +1,60 @@
+export const classifyTableColumn = [
+    {
+        title:'一级分类',
+        colKey:'ClassifyName',
+        minWidth:'200px',
+    },{
+        title:'二级分类',
+        colKey:'ParentId',
+    },{
+        title:'操作',
+        colKey:'opt',
+    }
+]
+
+export const labelTableColumn = [
+    {
+        title:'标签名称',
+        colKey:'TagName',
+        minWidth:'200px',
+    },{
+        title:'视频数',
+        colKey:'VideoTotal',
+    },{
+        title:'创建时间',
+        colKey:'CreateTime',
+        minWidth:'200px',
+    }
+]
+
+
+export const videoTableColumn = [
+    {
+        title:'视频封面',
+        colKey:'CoverImg',
+        Width:'300px',
+    },{
+        title:'视频名称',
+        colKey:'Title',
+        minWidth:'200px'
+    },{
+        title:'分类',
+        colKey:'Classify',
+        Width:'200px',
+    },{
+        title:'标签',
+        colKey:'Tags',
+        minWidth:'200px',
+    },{
+        title:'状态',
+        colKey:'PublishState',
+    },{
+        title:'创建时间',
+        colKey:'CreateTime',
+        Width:'200px',
+    },
+    {
+        title:'操作',
+        colKey:'opt',
+    }
+]

+ 32 - 0
src/views/training/css/manage.scss

@@ -0,0 +1,32 @@
+.traing-manage{
+    .top-wrap,.table-wrap{
+        padding:20px;
+        background-color:#fff;
+        border-radius: 4px;
+    }
+    .top-wrap{
+        display: flex;
+        justify-content: space-between;
+    }
+    .table-wrap{
+        margin-top: 20px;
+    }
+    .t-dialog{
+        .dialog-container{
+          padding-bottom: 20px;
+          .input-item{
+            margin-bottom:10px;
+          }
+          .form-hint{
+            color:#999999;
+            font-size: 12px;
+          }
+        }
+        .foot-container{
+          padding:20px 0;
+          display: flex;
+          gap: 10px;
+          justify-content: flex-end;
+        }
+      }
+}

+ 168 - 0
src/views/training/labelManage.vue

@@ -0,0 +1,168 @@
+<template>
+    <!-- 培训管理-标签管理 -->
+    <div class="label-manage-wrap traing-manage">
+        <div class="top-wrap">
+            <el-button type="primary" @click="handleModifyLabel({})">新增标签</el-button>
+            <el-input v-model="searchText" clearable 
+                prefix-icon="el-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 slot-scope="{row}">
+                        <el-button type="text" @click="handleModifyLabel(row)">编辑</el-button>
+                        <el-button type="text" @click="deleteLabel(row)" style="color:red;">删除</el-button>
+                    </template>
+                </el-table-column>
+            </el-table>
+            <el-pagination
+                layout="total,prev,pager,next,jumper" 
+                background
+                :current-page="currentPage"
+                @current-change="handleCurrentChange"
+                :page-size="pageSize" 
+                :total="total"
+                style="text-align:right;margin-top:30px;">
+            </el-pagination>
+        </div>
+        <!-- 添加标签弹窗 -->
+        <el-dialog
+            :title="currentLabel.TagId?'编辑标签':'添加标签'"
+            :visible.sync="isModifyDialogShow"
+            :close-on-click-modal="false"
+            :modal-append-to-body="false"
+            @close="isModifyDialogShow=false"
+            width="589px"
+            v-dialogDrag
+            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>
+
+<script>
+import {labelTableColumn} from './config/tableColumn'
+import {TagInterface} from '@/api/modules/trainingApi'
+export default {
+    data() {
+        return {
+            /* table */
+            tableLoading:false,
+            tableData:[],
+            tableColumn:labelTableColumn,
+            searchText:'',
+            /* table-page */
+            currentPage:1,
+            pageSize:10,
+            total:0,
+            /* modify label */
+            currentLabel:{},
+            isModifyDialogShow:false,
+        };
+    },
+    methods: {
+        handleModifyLabel(data){
+            this.currentLabel = _.cloneDeep(data)
+            this.isModifyDialogShow = true
+        },
+        async modifyLabel(){
+            if(!this.currentLabel.TagName){
+                this.$message.warning("请输入标签名称")
+                return
+            }
+            if(this.currentLabel.TagName.length>5){
+                this.$message.warning("标签名称过长,请重新编辑")
+                return
+            }
+            let res = null
+            if(this.currentLabel.TagId){
+                //edit
+                res = await TagInterface.editTag({
+                    TagId:this.currentLabel.TagId,
+                    TagName:this.currentLabel.TagName
+                })
+            }else{
+                //add
+                res = await TagInterface.addTag({
+                    TagName:this.currentLabel.TagName
+                })
+            }
+            if(res.Ret!==200) return 
+            //添加/编辑成功
+            this.$message.success(`${this.currentLabel.TagId?'编辑':'添加'}成功`)
+            this.currentPage = 1
+            this.getTableData()
+            this.isModifyDialogShow = false
+        },
+        deleteLabel(label){
+            if(label.VideoTotal!==0){
+                this.$confirm('该标签已关联视频,删除失败','提示',{confirmButtonText:'知道了',showCancelButton:false,type:'error'})
+                return
+            }
+            this.$confirm(
+                '删除后不可恢复,是否确认删除该标签?',
+                '提示',
+                {
+                confirmButtonText: '确定',
+                cancelButtonText: '取消',
+                type: 'warning',
+                }
+            ).then(()=>{
+                TagInterface.deleteTag({
+                    TagId:label.TagId
+                }).then(res=>{
+                    if(res.Ret!==200) return 
+                    this.$message.success('删除成功')
+                    this.currentPage=1
+                    this.getTableData()
+                })
+            }).catch(()=>{}).finally(()=>{})
+        },
+        getTableData(){
+            this.tableLoading = true
+            TagInterface.getTagList({
+                PageSize:this.pageSize,
+                CurrentIndex:this.currentPage,
+                Keyword:this.searchText
+            }).then(res=>{
+                this.tableLoading = false
+                if(res.Ret!==200) return
+                if(!res.Data){
+                    this.tableData = []
+                    this.total = 0
+                    return
+                } 
+                this.tableData = res.Data.List||[]
+                this.total = res.Data.Paging.Totals
+            })
+        },
+        handleCurrentChange(page){
+            this.currentPage = page
+            this.getTableData()
+        }
+    },
+    mounted(){
+        this.getTableData()
+    }
+};
+</script>
+
+<style scoped lang="scss">
+@import "./css/manage.scss";
+</style>

+ 489 - 0
src/views/training/modifyVideoPage.vue

@@ -0,0 +1,489 @@
+<template>
+    <!-- 新增编辑视频 -->
+    <div class="modify-video-page-wrap">
+        <t-form :data="form" :rules="rules" ref="formRules">
+            <t-form-item label="所属分类" name="ClassifyId">
+                <t-cascader placeholder="选择所属分类" 
+                    v-model="form.ClassifyId"
+                    :options="classifyList"
+                    style="width: 300px;"
+                    clearable
+                    :keys="{
+                      emitPath:false,
+                      label:'ClassifyName',
+                      value:'ClassifyId',
+                      children:'Children'}">
+                </t-cascader>
+            </t-form-item>
+            <t-form-item label="视频名称" name="Title">
+                <t-input style="width: 300px;" v-model="form.Title" placeholder="请输入视频名称"></t-input>
+            </t-form-item>
+            <t-form-item label="视频简介" name="Introduce">
+                <t-textarea  v-model="form.Introduce" 
+                placeholder="请输入视频简介"
+                maxlength="50" show-word-limit :autosize="{ minRows: 5, maxRows: 5 }"></t-textarea >
+            </t-form-item>
+            <t-form-item label="上传封面" name="CoverImg">
+              <div style="display: block;">
+                  <t-upload accept="image/*" 
+                      :http-request="handleUploadImg" :show-file-list="false" :disabled="isImageUploading">
+                      <t-button theme="primary" :loading="isImageUploading">点击上传</t-button>
+                      <span style="color:#999999;margin-left: 5px;" @click.stop>建议尺寸比例3:2,支持png、jpg、gif、jpeg格式</span>
+                  </t-upload>
+                  <div class="img-box">
+                      <img :src="form.CoverImg" v-if="form.CoverImg">
+                      <span v-else style="color:#999999;line-height: 100px;">请上传封面图</span>
+                  </div>
+              </div>
+            </t-form-item>
+            <t-form-item label="上传视频" name="VideoUrl">
+              <div style="display: block;">
+                  <t-upload accept=".mp4" 
+                      :request-method="handleUploadVideo" :show-file-list="false" :disabled="isVideoUploading">
+                      <t-button theme="primary" :loading="isVideoUploading">点击上传</t-button>
+                      <span style="color:#999999;margin-left: 5px;" @click.stop>仅支持mp4格式</span>
+                  </t-upload>
+                  
+                  <div class="img-box">
+                      <t-progress type="circle" :percentage="percentage" width="40" v-if="isVideoUploading"></t-progress>
+                      <span v-if="form.VideoUrl&&!form.VideoId" class="duration">{{timeDuration}}</span>
+                      <img :src="form.CoverImg" v-if="form.VideoUrl" @click="handlePreviewVideo">
+                      <span v-else style="color:#999999;line-height: 100px;">请上传视频</span>
+                  </div>
+              </div>
+                
+            </t-form-item>
+            <t-form-item label="视频标签" name="TagIds">
+                <t-button variant="text" theme="primary" @click="isModifyDialogShow = true">选择标签</t-button>
+                <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="t-icon-close"></i></span>
+                    </span>
+                </div>
+            </t-form-item>
+        </t-form>
+        <div class="btn-box">
+            <t-button variant="outline" plain @click="changeRoute">取消</t-button>
+            <t-button theme="primary" @click="modifyVideo" :loading="isImageUploading||isVideoUploading">保存</t-button>
+            <t-button theme="primary" @click="publishVideo" :loading="isImageUploading||isVideoUploading">发布</t-button>
+        </div>
+        <!-- 选择标签弹窗 -->
+        <AddTags 
+            :isModifyDialogShow="isModifyDialogShow"
+            :Tags="form.TagIds"
+            :tagList="tagList"
+            :getTagList="getTagList"
+            @modify="modifyTags"
+            @close="isModifyDialogShow=false"
+        />
+        <!-- 预览视频弹窗 -->
+        <t-dialog
+            :visible.sync="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>   
+        </t-dialog>
+    </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue';
+import MD5 from 'js-md5';
+import { VideoInterface, TagInterface, ClassifyInterface } from '@/api/modules/trainingApi';
+import AddTags from './components/addTags.vue';
+import _ from 'lodash';
+import { useRoute, useRouter } from 'vue-router'
+
+const router=useRouter()
+const route=useRoute()
+const form = ref({
+  Title: '',
+  Introduce: '',
+  ClassifyId: '',
+  TagIds: [],
+  CoverImg: '',
+  VideoUrl: ''
+});
+
+const rules = {
+  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();
+      }
+    }
+  }]
+}
+
+const tagIdKey = ref(0);
+const isModifyDialogShow = ref(false);
+
+const isImageUploading = ref(false);
+const isVideoUploading = ref(false);
+const percentage = ref(0);
+const timeDuration = ref('');
+const previewVideoUrl = ref('');
+const previewPop = ref(false);
+const previewPopTitle = ref('');
+const tagList = ref([]);
+const classifyList = ref([]);
+const previewVideo = ref(null);
+const formRules = ref(null);
+
+// 定义全局变量
+let ALOSSINS = null;
+let ALOSSAbortCheckpoint = null;
+
+
+// 获取分类列表
+const getClassifyList = async (type = '') => {
+  try {
+    const res = await ClassifyInterface.getClassifyList({});
+    if (res.Ret !== 200) return;
+    classifyList.value = (res.Data && res.Data.List) || [];
+    filterNodes(classifyList.value);
+    classifyList.value = classifyList.value.map(item => {
+      if (!item.Children) {
+        item.disabled = true;
+      }
+      return item;
+    });
+  } catch (error) {
+    console.error('获取分类列表失败:', error);
+  }
+};
+
+// 过滤节点
+const filterNodes = (arr) => {
+  if (arr.length) {
+    arr.forEach(item => {
+      if (item.Children && item.Children.length) {
+        filterNodes(item.Children);
+      }
+      if (item.Children && !item.Children.length) {
+        delete item.Children;
+      }
+    });
+  }
+};
+
+// 获取标签列表
+const getTagList = async (keyword = '') => {
+  try {
+    const res = await TagInterface.getTagList({
+      Keyword: keyword,
+      PageSize: 1000,
+      CurrentIndex: 1
+    });
+    if (res.Ret !== 200) return;
+    tagList.value = (res.Data && res.Data.List) || [];
+  } catch (error) {
+    console.error('获取标签列表失败:', error);
+  }
+};
+
+// 检查图片是否合法
+const handleUploadImg = (file) => {
+  isImageUploading.value = true;
+  const { type } = file.file;
+  if (!['image/png', 'image/jpeg'].includes(type)) {
+    MessagePlugin.warning('仅支持png、jpg格式的图片');
+    isImageUploading.value = false;
+    return;
+  }
+  uploadImg(file);
+};
+
+// 上传图片
+const uploadImg = (file) => {
+  const formData = new FormData();
+  formData.append('file', file.file);
+  VideoInterface.bannerupload(formData).then(res => {
+    isImageUploading.value = false;
+    if (res.Ret !== 200) return;
+    form.value.CoverImg = res.Data.ResourceUrl;
+  });
+};
+
+// 检查视频是否合法,并获取视频时长
+const handleUploadVideo = async (file) => {
+  if (file.type !== 'video/mp4') {
+    MessagePlugin.warning('上传失败,上传视频格式不正确');
+    return;
+  }
+  const duration = await handleGetDuration(file);
+  timeDuration.value = `${String(parseInt(duration / 60)).padStart(2, '0')}:${String(parseInt(duration % 60)).padStart(2, '0')}`;
+  uploadVideo(file);
+  isVideoUploading.value = true;
+};
+
+// 获取视频时长的Promise
+const handleGetDuration = (file) => {
+  console.log(file);
+  
+  return new Promise((resolve, reject) => {
+    const fileUrl = URL.createObjectURL(file.raw);
+    const audioEl = new Audio(fileUrl);
+    audioEl.addEventListener('loadedmetadata', (e) => {
+      const t = e.composedPath()[0].duration;
+      resolve(t);
+    });
+  });
+};
+
+// 上传视频
+const uploadVideo = async (file) => {
+  const res = await VideoInterface.getOSSSign();
+  if (res.Ret === 200) {
+    handleUploadToOSS(file.raw, res.Data);
+  }
+};
+
+// 上传到阿里云
+const handleUploadToOSS = async (file, { AccessKeyId, AccessKeySecret, SecurityToken }) => {
+  ALOSSINS = new OSS({
+    region: 'oss-cn-shanghai',
+    accessKeyId: AccessKeyId,
+    accessKeySecret: AccessKeySecret,
+    stsToken: SecurityToken,
+    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,
+    partSize: 1024 * 1024 * 10
+  };
+
+  try {
+    console.log(temName, file, { ...options });
+    
+    const res = await ALOSSINS.multipartUpload(temName, file, { ...options });
+    console.log('上传结果', res);
+    if (res.res.status === 200) {
+      form.value.VideoUrl = `https://hzstatic.hzinsights.com/${res.name}`;
+      percentage.value = 0;
+      ALOSSAbortCheckpoint = null;
+      isVideoUploading.value = false;
+    }
+  } catch (error) {
+    if (error.name !== 'cancel') {
+      MessagePlugin.warning('上传失败,请刷新重试');
+    }
+    percentage.value = 0;
+    ALOSSAbortCheckpoint = null;
+    isVideoUploading.value = false;
+  }
+};
+
+// 删除所选标签
+const removeTag = (tag) => {
+  const index = form.value.TagIds.findIndex(i => i.TagId === tag.TagId);
+  if (index !== -1) {
+    form.value.TagIds.splice(index, 1);
+  }
+  tagIdKey.value++;
+};
+
+// 改变所选标签
+const modifyTags = (tags) => {
+  form.value.TagIds = _.cloneDeep(tags);
+  isModifyDialogShow.value = false;
+};
+
+// 获取视频信息
+const getVideoDetail = () => {
+  const { VideoId } = route.query;
+  if (!Number(VideoId)) return;
+  VideoInterface.getVideoDetail({ VideoId: Number(VideoId) }).then(res => {
+    if (res.Ret !== 200) return;
+    form.value = res.Data || {};
+    if (form.value.Classify) {
+      const classifyArr = getDataClassify(form.value.Classify);
+      form.value.ClassifyId = classifyArr[classifyArr.length - 1];
+      delete form.value.Classify;
+    }
+    if (form.value.Tags) {
+      form.value.TagIds = form.value.Tags;
+      delete form.value.Tags;
+    }
+  });
+};
+
+// 获取视频分类路径
+const getDataClassify = (classify, classifyArr = []) => {
+  classifyArr.push(classify.ClassifyId);
+  if (classify.Children && classify.Children.length) {
+    return getDataClassify(classify.Children[0], classifyArr);
+  }
+  return classifyArr;
+};
+
+// 添加/编辑视频
+const modifyVideo = async (type = 'modify') => {
+  const valid = await formRules.value.validate()
+  if (valid !== true) return
+  let res = null;
+  const params = { ...form.value, TagIds: form.value.TagIds.map(t => t.TagId) };
+  if (!form.value.VideoId) {
+    res = await VideoInterface.addVideo(params);
+  } else {
+    res = await VideoInterface.editVideo(params);
+  }
+  if (res.Ret !== 200) return;
+  if (type !== 'publish') {
+    MessagePlugin.success(`${form.value.VideoId ? '编辑' : '添加'}成功`);
+    changeRoute();
+  }
+  if (!form.value.VideoId) {
+    form.value.VideoId = res.Data || '';
+  }
+};
+
+// 发布视频
+const publishVideo = async () => {
+  let res = {};
+  await modifyVideo('publish');
+  if (form.value.VideoId) {
+    res = await VideoInterface.publishVideo({ VideoId: Number(form.value.VideoId), PublishState: 1 });
+    if (res.Ret !== 200) return;
+    MessagePlugin.success('发布成功');
+  }
+  changeRoute();
+};
+
+// 改变路由
+const changeRoute = () => {
+  if (ALOSSAbortCheckpoint) {
+    console.log('终止上传');
+    ALOSSINS.abortMultipartUpload(ALOSSAbortCheckpoint.name, ALOSSAbortCheckpoint.uploadId);
+  }
+  router.push('/training/trainingVideo');
+};
+
+// 预览视频
+const handlePreviewVideo = () => {
+  if (isVideoUploading.value || !form.value.VideoUrl) return;
+  previewVideo.play();
+  previewPopTitle.value = form.value.Title || '暂无标题';
+  previewVideoUrl.value = form.value.VideoUrl;
+  previewPop.value = true;
+};
+
+// 结束预览弹窗关闭回调 -- 暂停视频
+const endingPreview = () => {
+  previewVideo.pause();
+};
+
+// 生命周期钩子 - 组件挂载时执行
+onMounted(() => {
+  getClassifyList('leaf');
+  getTagList();
+  getVideoDetail();
+});
+</script>
+
+<style lang="scss">
+.modify-video-page-wrap{
+    .t-textarea__inner{
+        resize: none;
+    }
+    .t-form-item{
+            .t-form-item__content{
+                display: flex;
+                flex-direction: column;
+                align-items:flex-start;
+            }
+        }
+}
+</style>
+<style scoped lang="scss">
+.modify-video-page-wrap{
+    box-sizing: border-box;
+    padding:40px;
+    background-color: #fff;
+    border-radius: 4px;
+    .t-form{
+        .t-input,.t-select,.t-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;
+            }
+            .t-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;
+            }
+        }
+    }
+    .btn-box{
+      margin-top: 20px;
+        text-align: center;
+        .t-button{
+            margin-right: 50px;
+            width:120px;
+            text-align: center;
+        }
+    }
+}
+</style>

+ 335 - 0
src/views/training/videoManage.vue

@@ -0,0 +1,335 @@
+<template>
+    <div class="video-manage-wrap traing-manage">
+        <div class="top-wrap">
+            <div class="select-box">
+                <t-date-range-picker
+                    v-model="datePick"
+                    format="YYYY-MM-DD"
+                    range-separator="至"
+                    start-placeholder="开始日期"
+                    end-placeholder="结束日期"
+                    class="date-picker"
+                    clearable
+                    @change="handleCurrentChange(1)">
+                </t-date-range-picker>
+                <t-cascader placeholder="选择分类" 
+                    v-model="ClassifyId"
+                    :options="classifyList"
+                    clearable
+                    :show-all-levels="false"
+                    :keys="{emitPath:false,
+                            checkStrictly:true,
+                            label:'ClassifyName',
+                            value:'ClassifyId',
+                            children:'Children'}"
+                    @change="handleCurrentChange(1)">
+                </t-cascader>
+                <t-select placeholder="选择标签" 
+                    :minCollapsedNum="2"
+                    v-model="TagIds" multiple clearable
+                    @change="handleCurrentChange(1)">
+                    <t-option
+                        v-for="item in tagList"
+                        :key="item.TagId"
+                        :label="item.TagName"
+                        :value="item.TagId">
+                    </t-option>
+                </t-select>
+                <t-select placeholder="选择状态" 
+                    v-model="PublishState" clearable
+                    @change="handleCurrentChange(1)">
+                    <t-option label="未发布" :value="1"/>
+                    <t-option label="已发布" :value="2"/>
+                </t-select>
+            </div>
+            <t-input v-model="searchText" clearable placeholder="请输入视频名称" @input="handleCurrentChange(1)" style="width:240px;">
+                <template #prefixIcon><SearchIcon /></template>
+            </t-input>
+        </div>
+        <div class="table-wrap">
+            <t-button theme="primary" @click="handleModifyVideo(0)">新增视频</t-button>
+            <t-table border :data="tableData" :columns="tableColumn" ref="dataRef" :bordered="true" :table-layout="'auto'" rowKey="EtaTrialId">
+                <template #CoverImg="{ row }">
+                    <!-- 视频封面 -->
+                    <div class="img-box">
+                        <img :src="row.CoverImg" @click="handlePreviewVideo(row)">
+                    </div>
+                </template>
+                <template #Classify="{ row }">
+                    <!-- 分类 -->
+                    <span>
+                        {{getDataClassify(row.Classify).join('/')}}
+                    </span>
+                </template>
+                <template #Tags="{ row }">
+                    <!-- 标签 -->
+                    <div class="label-box">
+                        <span class="label-item" v-for="tag in row.Tags" :key="tag.TagId">{{tag.TagName}}</span>
+                    </div>
+                </template>
+                <template #PublishState="{ row }">
+                    <!-- 发布状态 -->
+                    <span>{{row.PublishState===0?'未发布':'已发布'}}</span>
+                </template>
+                <template #opt="{ row }">
+                    <t-button variant="text" theme="primary" @click="handleModifyVideo(row.VideoId)" v-show="row.PublishState===0">编辑</t-button>
+                    <t-button variant="text" theme="primary" @click="publishVideo(row)">{{row.PublishState===0?'发布':'取消发布'}}</t-button>
+                    <t-button variant="text" theme="primary" @click="deleteVideo(row)" style="color:red;">删除</t-button>
+                </template>
+            </t-table>
+            <t-pagination
+                layout="total,prev,pager,next,jumper" 
+                background
+                :current-page="currentPage"
+                @current-change="handleCurrentChange"
+                :page-size="pageSize" 
+                :total="total"
+                style="text-align:right;margin-top:30px;">
+            </t-pagination>
+        </div>
+        <!-- 预览视频弹窗 -->
+        <t-dialog
+            :visible.sync="previewPop"
+            :modal-append-to-body='false'
+            width="60vw"
+            :header="previewPopTitle"
+            @close="endingPreview"
+        >
+            <video style="width: 100%;height: 100%;max-height: 70vh;outline: none;" 
+            controls :src="previewVideoUrl" autoplay ref="previewVideoRef">
+            您的浏览器暂不支持,请更换浏览器
+            </video>
+        </t-dialog>
+    </div>
+</template>
+
+
+<script setup>
+import { ref, onMounted } from 'vue';
+import { videoTableColumn } from './config/tableColumn';
+import { VideoInterface, TagInterface, ClassifyInterface } from '@/api/modules/trainingApi';
+import { SearchIcon } from 'tdesign-icons-vue-next';
+  import { useRouter } from 'vue-router'
+const router = useRouter();
+
+// 暴露给模板的 ref
+const previewVideoRef = ref(null);
+
+// 使用 ref 定义响应式数据
+const searchText = ref('');
+const datePick = ref([]);
+const ClassifyId = ref('');
+const TagIds = ref([]);
+const PublishState = ref('');
+
+const tableData = ref([]);
+const tableColumn = videoTableColumn;
+
+const currentPage = ref(1);
+const pageSize = ref(10);
+const total = ref(0);
+
+const previewPop = ref(false);
+const previewVideoUrl = ref('');
+const previewPopTitle = ref('');
+const tagList = ref([]);
+const classifyList = ref([]);
+
+// 获取视频分类路径
+const getDataClassify = (classify, classifyArr = []) => {
+  classifyArr.push(classify.ClassifyName);
+  if (classify.Children && classify.Children.length) {
+    return getDataClassify(classify.Children[0], classifyArr);
+  }
+  return classifyArr;
+};
+
+// 获取分类列表
+const getClassifyList = async (type = '') => {
+  try {
+    const res = await ClassifyInterface.getClassifyList({});
+    if (res.Ret !== 200) return;
+    classifyList.value = (res.Data && res.Data.List) || [];
+    filterNodes(classifyList.value);
+    classifyList.value = classifyList.value.map(item => {
+      if (!item.Children) {
+        item.disabled = true;
+      }
+      return item;
+    });
+  } catch (error) {
+    console.error('获取分类列表失败:', error);
+  }
+};
+
+// 过滤节点
+const filterNodes = (arr) => {
+  if (arr.length) {
+    arr.forEach(item => {
+      if (item.Children && item.Children.length) {
+        filterNodes(item.Children);
+      }
+      if (item.Children && !item.Children.length) {
+        delete item.Children;
+      }
+    });
+  }
+};
+
+// 获取标签列表
+const getTagList = async (keyword = '') => {
+  try {
+    const res = await TagInterface.getTagList({
+      Keyword: keyword,
+      PageSize: 1000,
+      CurrentIndex: 1
+    });
+    if (res.Ret !== 200) return;
+    tagList.value = (res.Data && res.Data.List) || [];
+  } catch (error) {
+    console.error('获取标签列表失败:', error);
+  }
+};
+
+// 获取视频列表
+const getTableData = async () => {
+  try {
+    const res = await VideoInterface.getVideoList({
+      PageSize: pageSize.value,
+      CurrentIndex: currentPage.value,
+      Keyword: searchText.value,
+      StartTime: datePick.value[0],
+      EndTime: datePick.value[1],
+      ClassifyId: Number(ClassifyId.value),
+      TagIds: TagIds.value.join(','),
+      PublishState: Number(PublishState.value),
+    });
+    if (res.Ret !== 200) return;
+    if (!res.Data) {
+      tableData.value = [];
+      total.value = 0;
+      return;
+    }
+    tableData.value = res.Data.List || [];
+    total.value = res.Data.Paging.Totals;
+  } catch (error) {
+    console.error('获取视频列表失败:', error);
+  }
+};
+
+// 修改视频
+const handleModifyVideo = (VideoId) => {
+  router.push({ path: '/training/modifyVideo', query: { VideoId } });
+};
+
+// 删除视频
+const deleteVideo = async (data) => {
+  const hint = data.PublishState === 0 ? '删除后不可恢复,是否确认删除?' : '该视频已发布,删除后取消发布并删除,是否确认删除?';
+    await  $confirmDialog({
+        body: hint,
+        confirmBtn:{default:'确认',theme:'danger'},
+    })
+    try {
+      const res = await VideoInterface.deleteVideo({ VideoId: data.VideoId });
+      if (res.Ret !== 200) return;
+      MessagePlugin.success('删除成功');
+      getTableData();
+    } catch (error) {
+      console.error('删除视频失败:', error);
+    }
+};
+
+// 发布/取消发布视频
+const publishVideo = async (data) => {
+    const hint = data.PublishState === 0 ? '是否确认发布' : '是否取消发布';
+    await  $confirmDialog({
+        body: hint,
+        confirmBtn:{default:'确认',theme:'danger'},
+    })
+    try {
+      const res = await VideoInterface.publishVideo({
+        VideoId: data.VideoId,
+        PublishState: data.PublishState === 0 ? 1 : 0,
+      });
+      if (res.Ret !== 200) return;
+      MessagePlugin.success(`${data.PublishState ? '取消发布' : '发布'}成功`);
+      getTableData();
+    } catch (error) {
+      console.error('发布/取消发布视频失败:', error);
+    }
+};
+
+// 切换页码
+const handleCurrentChange = (page) => {
+  currentPage.value = page;
+  getTableData();
+};
+
+// 预览视频
+const handlePreviewVideo = (data) => {
+  if (!data.VideoUrl) return;
+  if (previewVideoRef.value) {
+    previewVideoRef.value.play();
+    previewPopTitle.value = data.Title || '暂无标题';
+    previewVideoUrl.value = data.VideoUrl;
+    console.log(previewVideoUrl.value);
+    
+    previewPop.value = true;
+  }
+};
+
+// 结束预览弹窗关闭回调 -- 暂停视频
+const endingPreview = () => {
+  if (previewVideoRef.value) {
+    previewVideoRef.value.pause();
+    previewPop.value = false;
+  }
+};
+
+// 生命周期钩子 - 组件挂载时执行
+onMounted(() => {
+  getClassifyList(); // 假设这是 mixin 或组件内定义的方法
+  getTagList();     // 假设这是 mixin 或组件内定义的方法
+  getTableData();
+});
+</script>
+
+<style scoped lang="scss">
+@import "./css/manage.scss";
+.video-manage-wrap{
+    .select-box {
+        display: flex;
+        gap: 20px;
+        align-items: center;
+        .date-picker {
+            min-width: 390px;
+            margin-right: 15px;
+        }
+    }
+    .table-wrap{
+        .t-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>