11、Vue之分页组件(勾选、过滤)、五级联动组件、两种多层弹窗组件、问题与解决|深度选择器,收缩展开、各种滚动、拖拽、vue从虚拟DOM到真实DOM的过程, 自定义组件之图标|月季度半年全年|26小时|图片pdf水印组件(4900行)

一、vue之分页组件(含勾选、过滤)
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>勾选和分页组件之vue2.6.10版</title>
      <script src="https://cdn.bootcss.com/vue/2.6.10/vue.min.js"></script>
      <style>
        table{
          border-collapse: collapse;
          border: 1px solid #cbcbcb;
          width: 1000px;
        }
        table td,table th {
          padding: 5px;
          border: 1px solid #cbcbcb;
        }
        table thead {
          background-color: #e0e0e0;
          color: #000;
          text-align: left;
        }
        .filter{
          width:998px;
          border:1px solid gray;
          padding:10px 0px;
        }
        .line{
          display:flex
        }
        .group{
          width:330px;
        }
        .label{
          display: inline-block;
          width:120px;
          height: 24px;
          line-height: 24px;
          text-align: right;
        }
        .input{
          display: inline-block;
          width:180px;
          height: 24px;
          line-height: 24px;
          border-radius: 3px;
        }
        .select{
          display: inline-block;
          width:188px;
          height: 26px;
          line-height: 26x;
          border-radius: 3px;
        }
      </style>
    </head>
    <body>
      <div id="app">
        <div style="padding-bottom:5px;color:red">
          <button style="color:red" @click="checkDatasOne.getResultOfCheckAndFilter(divideDatasOne.isShowFilter,divideDatasOne.filterOptions)">获取勾选和过滤结果</button>
          <span>{{checkDatasOne.toServerDatas}}</span>
        </div>
        <div style="padding-bottom:5px">
          <img :src="checkDatasOne.stateAllPages&&checkDatasOne.allExcludedIds.length===0?checkImg.yes:checkImg.no" @click="checkDatasOne.clickAllPages(divideDatasOne.tableDatas)"/>
          <span>{{checkDatasOne.textAllPages}}</span>
        </div>
        <div style="padding-bottom:5px">
          <button @click="divideDatasOne.toggleShowFilter()">{{divideDatasOne.isShowFilter?'关闭过滤':'使用过滤'}}</button>
          <button @click="divideDatasOne.emptyFilterOptions({value5:10})">清空过滤</button> 
          <button @click="divideDatasOne.request(1,divideDatasOne.eachPageItemsNum)">刷新</button>
        </div>
        <div style="margin-bottom:5px" class="filter" v-show="divideDatasOne.isShowFilter">
          <div class="line">
            <div class="group">
              <label class="label">标签</label>
              <input class="input" type="text" v-model="divideDatasOne.filterOptions.value1" />
            </div>
            <div class="group">
              <label class="label">这就是长标签</label>
              <input class="input" type="text" v-model="divideDatasOne.filterOptions.value2" />
            </div>
            <div class="group">
              <label class="label">标签</label>
              <input class="input" type="text" v-model="divideDatasOne.filterOptions.value3" />
            </div>
          </div>
          <div class="line" style="padding-top: 10px;">
            <div class="group">
              <label class="label">这就是长标签</label>
              <input class="input" type="text" v-model="divideDatasOne.filterOptions.value4" />
            </div>
            <div class="group">
              <label class="label">下拉框</label>
              <select class="select" v-model="divideDatasOne.filterOptions.value5">
                <option v-for="item in selectOptions" :value="item.back">{{item.front}}</option>
              </select>
            </div>
            <div class="group">
              <label class="label"></label>
              <button style="width:188px;height:28px" @click="divideDatasOne.request(1,divideDatasOne.eachPageItemsNum)">过滤</button>
            </div>
          </div>
        </div>
        <div style="width: 1000px">
          <table>
            <thead>
            <tr>
              <th><img :src="checkDatasOne.stateThisPage?checkImg.yes:checkImg.no"
                @click="checkDatasOne.clickThisPage(divideDatasOne.tableDatas,divideDatasOne.allItemsNum)"/></th>
              <th>序号</th>
              <th>数据1</th>
              <th>数据2</th>
              <th>数据3</th>
              <th>数据4</th>
              <th>数据5</th>
              <th>数据6</th>
            </tr>
            </thead>
            <tbody>
            <tr v-for="(data,index) in divideDatasOne.tableDatas">
              <td><img :src="data.state?checkImg.yes:checkImg.no" @click="checkDatasOne.clickSingleItem(data,divideDatasOne.tableDatas,divideDatasOne.allItemsNum)"/></td>
              <td>{{(divideDatasOne.nowPageNum-1)*divideDatasOne.eachPageItemsNum + (index+1)}} </td>
              <td>{{ data.key1 }}</td>
              <td>{{ data.key2 }}</td>
              <td>{{ data.key3 }}</td>
              <td>{{ data.key4 }}</td>
              <td>{{ data.key5 }}</td>
              <td>{{ data.key6 }}</td>
            </tr>
            </tbody>
          </table>
        </div>
        <divide-page :divide-datas="divideDatasOne" :check-datas="checkDatasOne" :fixed-datas="fixedDatas"></divide-page>
      </div>
    </body>
    <script>
      new Vue({
        el: '#app',
        data(){
          return {
            divideDatasOne:{
              nowPageNum:0,
              allPagesNum:0,
              allItemsNum:0,
              eachPageItemsNum:0,
              tableDatas:[],
              filterOptions:{value5:10},
              isShowFilter:false,
              otherDatas:{}
            },
            checkDatasOne:{
              idKey: 'id',//每条数据的唯一标志
              stateThisPage: false,//当前页所有项是否全选
              allIncludedIds: [],//所有被选中数据的ID构成的数组
              allExcludedIds: [],//所有没被选中数据的ID构成的数组
              textAllPages: '全选未启用,没有选择任何项!',//复选框被点击后的提示文字。
              stateAllPages: false,//复选框被点击后的提示文字。
              toServerDatas: null,
            },
          }
        },
        methods: {
    
        },
        created(){
          this.fixedDatas = {};
          this.selectOptions = [
            { back: 10, front: '来' },
            { back: 20, front: '来自于' },
            { back: 30, front: '来自于国内' },
            { back: 40, front: '来自于国内攻击' },
            { back: 50, front: '来自于国内攻击-2' }
          ];
          this.checkImg = {
            yes: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAADqADAAQAAAABAAAADgAAAAC98Dn6AAAA+UlEQVQoFZWSMU4DMRBF/584G7QSRcIxuAZKEykNEiUVHVTQRaKh4AIcgAvQpkukVDlBOAYNSGSlXXuwpViyYYFdS9aMZ/6bsezh5HZ3T2KhqkfosEhWqnjkyd1u3xWKdQMsfaEAB0Zilf8swfdU0w0klmpGpz1BvpbHcklbPf8Okts0CfJtWBTz/Yc++Jc8S3PZVQfKGwiuvMD6XYsMzm1dT/1jXKdQ8E0asHRrAzOzbC6UGINWHPQp1UQ/6wjF2LpmJSKfhti4Bi8+lhWP4I+gAqV1uqSi8j9WRuF3m3eMWVUJBeKxzUoYn7bEX7HDyPmB7QEHbRjyL+/+VnuXDUFOAAAAAElFTkSuQmCC',
            no: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAADqADAAQAAAABAAAADgAAAAC98Dn6AAAAbklEQVQoFWM8c+ZMLQMDQxUQcwAxMeAHUFEbC5CoYmNj02ZmZn5FjK6/f/+K/fr16ypIIwdIk7a29hdiNF69ehWkjIOJGMXY1IxqxBYqULEhFDiglPMDlIygKQKPryBSILUgPSCNbaC0B6RJSuQAbowizhJuOsAAAAAASUVORK5CYII=',
          }
        },
        components: {
          dividePage: {
            props: {
              divideDatas: {
                type: Object,
                default: {}
              },
              checkDatas: {
                type: Object,
                default: {}
              },
              fixedDatas: {
                type: Object,
                default: {}
              }
            },
            template: `
              <div v-show="divideDatas.allPagesNum>=1" style="display:flex;width:1000px;margin-top:20px;">
                <div>
                  <button
                    v-show="divideDatas.allPagesNum>10"
                    @click="clickDividePage('front') "
                    :disabled="divideDatas.nowPageNum===1"
                  >上一页</button>
                  <button 
                    :disabled="number==='...'"
                    v-for="number in divideArray"
                    @click="clickDividePage(number)"
                    :style="{marginRight:'5px',color:number===divideDatas.nowPageNum?'red':'gray'}"
                  >{{ number }}</button>
                  <button
                    v-show="divideDatas.allPagesNum>10"
                    @click="clickDividePage('back')" 
                    :disabled="divideDatas.nowPageNum===divideDatas.allPagesNum"
                  >下一页</button>
                </div>
                <div style="display:flex; flex:1; justify-content:flex-end;">
                  <div style="margin-right:20px;">
                    <span>转到第</span>
                    <input type="text" v-model="customString" @keydown="clickDividePage('leap',$event)" style="width:30px;">
                    <span>页</span>
                    <button @click="clickDividePage('leap',{which:13})">Go</button>
                  </div>
                  <div>
                    <span>每页显示</span>
                    <select v-model="divideDatas.eachPageItemsNum" @change="selectChange(divideDatas.eachPageItemsNum)">
                      <option v-for="item in numOptions" :value="item.back">{{item.front}}</option>
                    </select>
                    <span>条,</span>
                  </div>
                  <div>
                    <span>{{frontMoreText}}</span>
                    <span>{{totalText}}</span>
                    <span>{{divideDatas.allItemsNum}}</span>
                    <span>{{totalUnit}}</span>
                    <span>{{backMoreText}}</span>
                  </div>
                </div>  
              </div
            `,
            data() {
              return {
                customString:''
              }
            },
            created(){
              var that = this;
              //1、请求配置
              this.url = this.fixedDatas.url || '';
              this.method = this.fixedDatas.method || 'post';
              this.isShowParams = this.fixedDatas.isShowParams || false;//显式还是隐式传参。有时需要在请求发出前手动改变。
              //2、响应配置(前端通过这个配置,获取后台的数据)
              this.nowPageNum = this.fixedDatas.nowPageNum || 'nowPageNum';//来自服务器的当前页码
              this.allPagesNum = this.fixedDatas.allPagesNum || 'allPagesNum';//来自服务器的所有页页数
              this.allItemsNum = this.fixedDatas.allItemsNum || 'allItemsNum';//来自服务器的所有页数据数
              this.eachPageItemsNum = this.fixedDatas.eachPageItemsNum || 'eachPageItemsNum';//来自服务器的每页最多数据数
              this.tableDatas = this.fixedDatas.tableDatas || 'tableDatas';//来自服务器的表格数据
              //3、以下配置使用哪种转圈方式(前端根据需要决定,不受后台影响)
              this.partCircle = this.fixedDatas.partCircle;//局部是否转圈。this.fixedDatas.partCircle=$scope.partCircle={isShow =false}。
              this.isUsePartCircle = this.fixedDatas.isUsePartCircle;//局部是否转圈,由当前页的一个变量控制
              this.isUseWholeCircle = this.fixedDatas.isUseWholeCircle;//全局是否转圈,由本项目的一个服务控制
              //4、初始化以下数据,供页面使用(前端根据需要决定,不受后台影响)
              this.frontMoreText = this.fixedDatas.frontMoreText || "";//('文字 ')或者("文字 "+result.numOne+" 文字 ")
              this.totalText = this.fixedDatas.totalText || "";//'共'
              this.totalUnit = this.fixedDatas.totalUnit || '条';//总数据的单位
              this.backMoreText = this.fixedDatas.backMoreText || "";//(' 文字')或者("文字 "+result.numThree+" 文字")
              this.numOptions = [
                { back: 10, front: 10 },
                { back: 20, front: 20 },
                { back: 30, front: 30 },
                { back: 40, front: 40 },
                { back: 50, front: 50 }
              ];
              this.request = this.divideDatas.request = function (nowPageNum,eachPageItemsNum) {
                var isOnce = true;
                //1、向后台发送请求,
                //2、返回正确结果result
                var data=[];
                var allItemsNum = 193;
                var nowPageNum = nowPageNum||1;
                var eachPageItemsNum = eachPageItemsNum||10;
                var allPagesNum = Math.ceil(allItemsNum/eachPageItemsNum);
                for(var i=1;i<=allItemsNum;i++){
                  var obj={
                    id:'id'+i,
                    key1:'数据'+(i+0),
                    key2:'数据'+(i+1),
                    key3:'数据'+(i+2),
                    key4:'数据'+(i+3),
                    key5:'数据'+(i+4),
                    key6:'数据'+(i+5),
                    key7:'数据'+(i+6),
                  };
                  data.push(obj)
                }
                var tableDatas = data.slice((nowPageNum-1)*eachPageItemsNum,nowPageNum*eachPageItemsNum);
                //3、使用正确结果result
                that.customString = nowPageNum;
                that.divideDatas.nowPageNum = nowPageNum;
                that.divideDatas.allPagesNum = allPagesNum;
                that.divideDatas.allItemsNum = allItemsNum;
                that.divideDatas.eachPageItemsNum = eachPageItemsNum;
                that.divideDatas.tableDatas = tableDatas;//正常逻辑
                if(that.divideDatas.once && isOnce){//只使用一次,常用于初始化一些数据,比如过滤条件
                  that.divideDatas.once();
                  isOnce = false;
                }
                if(that.divideDatas.trueCb){//异常逻辑
                  that.divideDatas.trueCb()
                }
                if(that.checkDatas && that.checkDatas.signCheckbox){//处理勾选
                  that.checkDatas.signCheckbox(that.divideDatas.tableDatas)
                }
                that.createDividePage();//创建分页
                //4、处理错误结果
                if(that.divideDatas.errorCb){
                  that.divideDatas.errorCb()
                }
              };
              if (!this.divideDatas.isAbandonInit) {
                this.request(1,this.divideDatas.eachPageItemsNum);
              };
              this.divideDatas.toggleShowFilter = function () {
                this.isShowFilter = !this.isShowFilter;
                if (!this.isShowFilter) {
                  this.request(1,that.divideDatas.eachPageItemsNum);
                }
              };
              this.divideDatas.emptyFilterOptions = function (extraObject) {
                //清空选项时,所有值恢复成默认
                for(var key in this.filterOptions){
                  this.filterOptions[key] = undefined;
                };
                if (extraObject) {
                  //小部分选项的默认值不是undefined
                  for(var key in extraObject){
                    this.filterOptions[key] = extraObject[key];
                  };
                };
                this.request(1,that.divideDatas.eachPageItemsNum);
              };
              this.checkDatas.init=function(){//点击“刷新”、“过滤”、“清除过滤”时执行
                this.idKey = idKey ? idKey : 'id';
                this.allIncludedIds = [];
                this.allExcludedIds = [];
                this.textAllPages = '全选未启用,没有选择任何项!';
                this.stateAllPages = false;
                this.stateThisPage = false;
              };
              this.checkDatas.clickAllPages = function (itemArray) {//所有页所有条目全选复选框被点击时执行的函数
                if(this.stateAllPages){
                  if(this.allExcludedIds.length>0){
                    this.stateAllPages = true;
                    this.stateThisPage = true;
                    this.textAllPages= '全选已启用,没有排除任何项!';
                    itemArray.forEach(function (item) {
                      item.state = true;
                    });
                  }else if(this.allExcludedIds.length==0){
                    this.stateAllPages = false;
                    this.stateThisPage = false;
                    this.textAllPages= '全选未启用,没有选择任何项!';
                    itemArray.forEach(function (item) {
                      item.state = false;
                    });
                  }
                }else{
                  this.stateAllPages = true;
                  this.stateThisPage = true;
                  this.textAllPages= '全选已启用,没有排除任何项!';
                  itemArray.forEach(function (item) {
                    item.state = true;
                  });
                }
                this.allExcludedIds = [];
                this.allIncludedIds = [];
              };
              this.checkDatas.clickThisPage = function (itemsArray,allItemsNum) {//当前页所有条目全选复选框被点击时执行的函数
                var that = this;
                this.stateThisPage = !this.stateThisPage
                itemsArray.forEach(function (item) {
                  item.state = that.stateThisPage;
                  if (item.state) {
                    that.delID(item[that.idKey], that.allExcludedIds);
                    that.addID(item[that.idKey], that.allIncludedIds);
                  } else {
                    that.delID(item[that.idKey], that.allIncludedIds);
                    that.addID(item[that.idKey], that.allExcludedIds);
                  }
                });
                if(this.stateAllPages){
                  if(this.stateThisPage && this.allExcludedIds.length === 0){
                    this.textAllPages = '全选已启用,没有排除任何项!';
                  }else{
                    this.textAllPages = '全选已启用,已排除'+ this.allExcludedIds.length + '项!排除项的ID为:' + this.allExcludedIds;
                  }
                }else{
                  if(!this.stateThisPage && this.allIncludedIds.length === 0){
                    this.textAllPages='全选未启用,没有选择任何项!';
                  }else{
                    this.textAllPages = '全选未启用,已选择' + this.allIncludedIds.length + '项!选择项的ID为:' + this.allIncludedIds;
                  }
                }
              };
              this.checkDatas.clickSingleItem = function (item, itemsArray, allItemsNum) {//当前页单个条目复选框被点击时执行的函数
                var that = this;
                item.state = !item.state;
                if (item.state) {
                  this.stateThisPage = true;
                  this.addID(item[this.idKey], this.allIncludedIds);
                  this.delID(item[this.idKey], this.allExcludedIds);
                  itemsArray.forEach( function (item) {
                    if (!item.state) {
                      that.stateThisPage = false;
                    }
                  });
                } else {
                  this.stateThisPage = false;
                  this.addID(item[this.idKey], this.allExcludedIds);
                  this.delID(item[this.idKey], this.allIncludedIds);
                }
                if(this.stateAllPages){
                  if(this.stateThisPage && this.allExcludedIds.length === 0){
                    this.textAllPages = '全选已启用,没有排除任何项!';
                  }else{
                    this.textAllPages = '全选已启用,已排除'+ this.allExcludedIds.length + '项!排除项的ID为:' + this.allExcludedIds;
                  }
                }else{
                  if(!this.stateThisPage && this.allIncludedIds.length === 0){
                    this.textAllPages='全选未启用,没有选择任何项!';
                  }else{
                    this.textAllPages = '全选未启用,已选择' + this.allIncludedIds.length + '项!选择项的ID为:' + this.allIncludedIds;
                  }
                }
              };
              this.checkDatas.signCheckbox = function (itemsArray) {//标注当前页被选中的条目,在翻页成功后执行。
                var that = this;
                if(this.stateAllPages){
                  this.stateThisPage = true;
                  itemsArray.forEach(function (item) {
                    var thisID = item[that.idKey];
                    var index = that.allExcludedIds.indexOf(thisID);
                    if (index > -1) {
                      item.state = false;
                      that.stateThisPage = false;
                    } else {
                      item.state = true;
                    }
                  });
                }else{
                  this.stateThisPage = true;
                  itemsArray.forEach( function (item) {
                    var thisID = item[that.idKey];
                    var index = that.allIncludedIds.indexOf(thisID);
                    if (index === -1) {
                      item.state = false;
                      that.stateThisPage = false;
                    }
                  });
                }
              };
              this.checkDatas.addID = function (id, idArray) {
                var index = idArray.indexOf(id);
                if (index === -1) {
                  idArray.push(id);//如果当前页的单项既有勾选又有非勾选,这时勾选当前页全选,需要这个判断,以免重复添加
                }
              };
              this.checkDatas.delID = function (id, idArray) {
                var index = idArray.indexOf(id);
                if (index > -1) {
                  idArray.splice(index, 1)
                }
              };
              this.checkDatas.getResultOfCheckAndFilter = function (isShowFilter,filterOptions) {//获取发送给后台的所有参数。
                var toServerDatas;
                var allIncludedIds = that.deepClone(this.allIncludedIds);
                var allExcludedIds = that.deepClone(this.allExcludedIds);
                if (!this.stateAllPages) {
                  if (allIncludedIds.length === 0) {
                    //return 弹窗告知:没有勾选项
                  }
                  toServerDatas = {
                    isSelectAll: false,
                    allIncludedIds: allIncludedIds,
                  }
                }else {
                  toServerDatas = { //exclude
                    isSelectAll: true,
                    allExcludedIds: allExcludedIds,
                  };
                }
                if (isShowFilter) {
                  for(var key in filterOptions){
                    toServerDatas[key]=filterOptions[key]
                  }
                }
                this.toServerDatas=toServerDatas;//这行代码在实际项目中不需要
                return toServerDatas;
              }
            },
            methods: {
              deepClone : function (arrayOrObject) {
                function isArray(value) { return {}.toString.call(value) === "[object Array]"; }
                function isObject(value) { return {}.toString.call(value) === "[object Object]"; }
                var target = null;
                if (isArray(arrayOrObject)) target = [];
                if (isObject(arrayOrObject)) target = {};
                for (var key in arrayOrObject) {
                  var value = arrayOrObject[key];
                  if (isArray(value) || isObject(value)) {
                    target[key] = deepClone(value);
                  } else {
                    target[key] = value;
                  }
                }
                return target;
              },
              selectChange:function(eachPageItemsNum){
                this.divideDatas.eachPageItemsNum = eachPageItemsNum;
                this.request(1,eachPageItemsNum);
              },
              createDividePage : function () {
                var divideArray = [];
                var allPagesNum = this.divideDatas.allPagesNum;
                var nowPageNum = this.divideDatas.nowPageNum;
                if (allPagesNum >= 1 && allPagesNum <= 10) {
                  for (var i = 1; i <= allPagesNum; i++) {
                    divideArray.push(i);
                  }
                } else if (allPagesNum >= 11) {
                  if (nowPageNum > 6) {
                    divideArray.push(1);
                    divideArray.push(2);
                    divideArray.push(3);
                    divideArray.push('...');
                    divideArray.push(nowPageNum - 1);
                    divideArray.push(nowPageNum);
                  } else {
                    for (i = 1; i <= nowPageNum; i++) {
                      divideArray.push(i);
                    }
                  }
                  //以上当前页的左边,以下当前页的右边
                  if (allPagesNum - nowPageNum >= 6) {
                    divideArray.push(nowPageNum + 1);
                    divideArray.push(nowPageNum + 2);
                    divideArray.push('...');
                    divideArray.push(allPagesNum - 2);
                    divideArray.push(allPagesNum - 1);
                    divideArray.push(allPagesNum);
                  } else {
                    for (var i = nowPageNum + 1; i <= allPagesNum; i++) {
                      divideArray.push(i);
                    }
                  }
                }
                this.divideArray = divideArray;
              },
              clickDividePage : function (stringOfNum, event) {
                var allPagesNum = this.divideDatas.allPagesNum;
                var nowPageNum = this.divideDatas.nowPageNum;
                if (stringOfNum === 'front' && nowPageNum != 1) {
                  nowPageNum--;
                } else if (stringOfNum === 'back' && nowPageNum != allPagesNum) {
                  nowPageNum++;
                } else if (stringOfNum === 'leap') {
                  if (event.which != 13) return;//不拦截情形:(1)聚焦输入框、按“Enter”键时;(2)点击“GO”时
                  var customNum = Math.ceil(parseFloat(this.customString));
                  if (customNum < 1 || customNum == 'NaN') {
                    nowPageNum = 1;//不给提示
                  } else if(customNum > allPagesNum) {
                    nowPageNum = allPagesNum;//不给提示
                  } else {
                    nowPageNum = customNum;
                  }
                } else {
                  nowPageNum = Math.ceil(parseFloat(stringOfNum));
                }
                this.request(nowPageNum,this.divideDatas.eachPageItemsNum);
              },
            }
          }
        },
      })
    </script>
  </html>

二、Vue之五级联动组件-省、市、县、乡、村
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="UTF-8">
      <title>Vue之五级联动组件-省、市、县、乡、村</title>
    </head>
    <style type="text/css">
      .edit{
        height:40px;
        line-height: 40px;
      }
      .paddingBottom{
        padding-bottom: 40px;
      }
      .question-select {
        height: 60px;
      }
      .question-select select {
        border-radius: 5px;
        box-shadow: 0 0 5px #666;
        appearance: none;
        -webkit-appearance: none;
        -moz-appearance: none;
        border: none;
        outline: none;
        height: 40px;
        padding: 0 20px;
        color: #333;
        font-size: 22px;
      }
      .question-select select.short {
        width: 120px;
      }
      .question-select select.long {
        width: 240px;
      }
      .birth-year{
        width:90px;
        margin-right: 20px;
        height: 100px;
        overflow-y: scroll;
      }
      .birth-month{
        width:70px;
        margin-right: 20px;
      }
      .birth-date{
        width:70px;
      }
    </style>
    <body>
      <div id="component">
        <five-grade :all-datas="allDatas"></five-grade>
      </div>
    </body>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <!-- Vue.js v2.6.10 -->
    <script type="text/javascript">
      Vue.component('five-grade', {
        template: `
          <div>
            <div class="question-select">
              <select v-model="singleProvince" class="short" @change="selectName(singleProvince)">
                <option v-for="key in allProvinces" :value="key" v-text="key"></option>
              </select>
              <select v-model="singleCity" v-show="singleProvince" class="short" @change="selectName(singleProvince,singleCity)">
                <option v-for="key in allCitys" :value="key" v-text="key"></option>
              </select>
              <select v-model="singleCounty" v-show="singleCity" class="short" @change="selectName(singleProvince,singleCity,singleCounty)">
                <option v-for="key in allCountys" :value="key" v-text="key"></option>
              </select>
              <select v-model="singleTown" v-show="singleCounty" class="long" @change="selectName(singleProvince,singleCity,singleCounty,singleTown)">
                <option v-for="key in allTowns" :value="key" v-text="key"></option>
              </select>
              <select v-model="singleVillage" v-show="singleTown" class="long" @change="selectName(singleProvince,singleCity,singleCounty,singleTown,singleVillage)">
                <option v-for="key in allVillages" :value="key" v-text="key"></option>
              </select>
            </div>
            <div class="paddingBottom">{{address}}</div>
          </div>
        `,
        props: {      
          allDatas: {
            type: Object
          }
        },
        data: function(){
          return {
            allProvinces: [],
            singleProvince: '',
            allCitys: [],
            singleCity: '',
            allCountys: [],
            singleCounty: '',
            allTowns: [],
            singleTown: '',
            allVillages: [],
            singleVillage: '',
            address: '',
          }
        },
        beforeMount: function () {
          this.selectName();
        },
        methods: {
          selectName: function(singleProvince,singleCity,singleCounty,singleTown,singleVillage){
            var siteArray = [singleProvince,singleCity,singleCounty,singleTown,singleVillage];
            var modelArray = ['singleProvince','singleCity','singleCounty','singleTown','singleVillage'];
            var optionsArray = ['allProvinces','allCitys','allCountys','allTowns','allVillages'];
            var allDatasNext;
            var address = "你选择的地址是:";
            for(var i=0;i<siteArray.length;i++){//遍历数组所有项
              if(!siteArray[i]) this[modelArray[i]] = '';
            }
            for(var i=0;i<siteArray.length;i++){//遍历数组至undefined第1次出现时停止
              allDatasNext = i == 0 ? this.allDatas : allDatasNext[siteArray[i-1]];
              if(!siteArray[i]) {
                var array = [];
                for (var key in allDatasNext) {
                  array.push(key)
                }
                this[optionsArray[i]] = array;
                break;
              }
            }
            if(this.singleProvince) address += this.singleProvince;
            if(this.singleCity) address += '-' + this.singleCity;
            if(this.singleCounty) address += '-' + this.singleCounty;
            if(this.singleTown) address += '-' + this.singleTown;
            if(this.singleVillage) address += '-' + this.singleVillage;
            this.address = address;
          }
        },
      });
      new Vue({
        el: '#component',
        data(){
          return {
            allDatas : makeallDatas()
          }
        },
        methods:{
        }
      })
      function makeallDatas() {
        var allDatas = {
          "北京市": {
            "区": {
              "通州区": {
                "中仓街道办事处": {
                  "滨河社区居委会": "110112001029",
                  "运河湾社区居委会": "110112001030"
                },
                "漷县镇": {
                  "后元化村委会": "110112106260",
                  "前元化村委会": "110112106261"
                }
              },
              "昌平区": {
                "天通苑南街道办事处":{
                  "东辰社区居委会":"110114009001",
                  "佳运园社区居委会":"110114009002",
                  "天通苑第二社区居委会":"110114009003",
                  "天通西苑第一社区居委会":"110114009004",
                  "天通东苑第一社区居委会":"110114009005",
                  "天通东苑第二社区居委会":"110114009006",
                  "天通苑第一社区居委会":"110114009007",
                  "嘉诚花园社区居委会":"110114009008",
                },
                "霍营街道办事处": {
                  "华龙苑南里社区居委会": "110114010001",
                  "华龙苑北里社区居委会": "110114010002",
                  "蓝天园社区居委会": "110114010003",
                  "天鑫家园社区居委会": "110114010004",
                  "霍营小区社区居委会": "110114010005",
                  "上坡佳园社区居委会": "110114010006",
                  "华龙苑中里社区居委会": "110114010007",
                }
              }
            },
            "县": {
              "密云县": {
                "密云镇": {
                  "小唐庄社区居委会": "110228100001",
                  "李各庄社区居委会": "110228100002",
                  "大唐庄社区居委会": "110228100003",
                  "季庄村委会": "110228100205",
                },
                "溪翁庄镇": {
                  "东智北村委会": "110228101209",
                  "石墙沟村委会": "110228101210",
                  "黑山寺村委会": "110228101211",
                  "立新庄村委会": "110228101212",
                },
              },
              "延庆县": {
                "旧县镇": {
                  "常里营村委会": "110229104215",
                  "盆窑村委会": "110229104216",
                  "团山村委会": "110229104217",
                  "大柏老村委会": "110229104218",
                },
                "珍珠泉乡": {
                  "双金草村委会": "110229214209",
                  "小川村委会": "110229214210",
                  "小铺村委会": "110229214211",
                  "仓米道村委会": "110229214212",
                }
              }
            }
          },
          "河南省": {
            "郑州市": {
              "金水区": {
                "凤凰台街道办事处": {
                  "凤凰台社区居民委员会": "410105013004",
                  "王庄社区居民委员会": "410105013005",
                  "张庄社区居民委员会": "410105013006",
                  "凤凰城社区居民委员会": "410105013007",
                },
                "金光路街道办事处": {
                  "徐庄村委会": "410105564201",
                  "贾陈村委会": "410105564202",
                  "柳园口村委会": "410105564203",
                  "马楼村委会": "410105564204",
                }
              },
              "登封市": {
                "嵩阳街道办事处": {
                  "玉溪路居委会": "410185001014",
                  "苹果园居委会": "410185001015",
                  "颖河路居委会": "410185001016",
                  "守敬路居委会": "410185001017",
                },
                "少林街道办事处": {
                  "塔沟居委会": "410185002001",
                  "少林居委会": "410185002002",
                  "耿庄村委会": "410185002203",
                  "王庄村委会": "410185002204",
                },
                "送表矿区": {
                  "东送表村委会": "410185400201",
                  "梁庄村委会": "410185400202",
                  "安庄村委会": "410185400203",
                  "刘楼村委会": "410185400204",
                }
              }
            },
            "信阳市": {
              "浉河区": {
                "老城街道办事处": {
                  "义阳居委会": "411502001001",
                  "三里店居委会": "411502001003",
                  "东方红居委会": "411502001004",
                  "浉河居委会": "411502001007",
                },
                "民权街道办事处": {
                  "民权居委会": "411502002001",
                  "新生居委会": "411502002002",
                  "成功居委会": "411502002003",
                  "白果树居委会": "411502002004",
                },
                "车站街道办事处": {
                  "工区东居委会": "411502003001",
                  "工区路居委会": "411502003002",
                  "六里棚居委会": "411502003003",
                  "新公房居委会": "411502003004",
                }
              },
              "固始县": {
                "段集镇": {
                  "街道居委会": "411525109001",
                  "段集村委会": "411525109201",
                  "棠树岗村委会": "411525109202",
                  "青峰村委会": "411525109203",
                  "下楼村委会": "411525109204",
                  "桂岭村委会": "411525109205",
                  "窑沟村委会": "411525109206",
                  "五尖山村委会": "411525109207",
                  "柳林村委会": "411525109208",
                  "齐山村委会": "411525109209",
                  "钓鱼台村委会": "411525109210",
                  "蒋营村委会": "411525109211",
                  "庙山村委会": "411525109212",
                  "汪旱庄村委会": "411525109213",
                  "乐道村委会": "411525109214",
                  "姚老家村委会": "411525109215",
                  "赵营村委会": "411525109216",
                  "童庙村委会": "411525109217",
                  "高庙村委会": "411525109218"
                },
                "武庙集镇": {
                  "街道居委会": "411525113001",
                  "汪小庄村委会": "411525113201",
                  "黄土岭村委会": "411525113202",
                  "平阳村委会": "411525113203",
                  "长江河村委会": "411525113204",
                  "锁口村委会": "411525113205",
                  "皮冲村委会": "411525113206",
                  "刘中楼村委会": "411525113207",
                  "迎水寺村委会": "411525113208",
                  "余楼村委会": "411525113209",
                  "钱老楼村委会": "411525113210",
                  "新店村委会": "411525113211",
                  "徐小店村委会": "411525113212",
                  "邓岭村委会": "411525113213",
                  "太平村委会": "411525113214",
                  "汪楼村委会": "411525113215",
                  "李瓦房村委会": "411525113216"
                },
                "祖师庙镇": {
                  "祖师庙居委会": "411525117001",
                  "仰天洼社区居委会": "411525117002",
                  "小店村委会": "411525117201",
                  "王行村委会": "411525117202",
                  "大冲村委会": "411525117203",
                  "松林村委会": "411525117204",
                  "刘楼村委会": "411525117205",
                  "羁马村委会": "411525117206",
                  "黄楼村委会": "411525117207",
                  "彭畈村委会": "411525117208",
                  "童圩村委会": "411525117209",
                  "仓房村委会": "411525117210",
                  "毛店村委会": "411525117211",
                  "万岗村委会": "411525117212",
                  "七冲村委会": "411525117213",
                  "三区村委会": "411525117214",
                  "杨楼村委会": "411525117215"
                }
              },
              "息县": {
                "杨店乡": {
                  "杨店村委会": "411528204200",
                  "安寨村委会": "411528204201",
                  "何庄村委会": "411528204202",
                  "李大庄村委会": "411528204203",
                },
                "张陶乡": {
                  "张陶村委会": "411528205200",
                  "曹林村委会": "411528205201",
                  "陈圈行村委会": "411528205202",
                  "大陈庄村委会": "411528205203",
                },
                "白土店乡": {
                  "白土店村委会": "411528206200",
                  "白衣阁村委会": "411528206201",
                  "大江庄村委会": "411528206202",
                  "桂庄村委会": "411528206203",
                },
                "岗李店乡": {
                  "岗李店村委会": "411528207200",
                  "大彭庄村委会": "411528207201",
                  "方老庄村委会": "411528207202",
                  "贾后寨村委会": "411528207203",
                }
              }
            },
          },
        };
        return allDatas;
      }
    </script>
  </html>

三、2种三层弹窗组件之simple-dialog
1、不可拖拽
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8">
      <title>Vue多层弹窗</title>
      <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
      <style>
        .simpleDialog {
          position: fixed;
          width: 100%;
          height: 100%;
          top: 0;
          left: 0;
          display: flex;
          justify-content: center;
          align-items: center;
        }
        .simpleDialog .mask {
          position: fixed;
          width: 100%;
          height: 100%;
          top: 0;
          left: 0;
          background: black;
          opacity: 0.5;
        }
        .simpleDialog .content {
          position: fixed;
          background: white;
          opacity: 1;
          display: flex;
          flex-direction: column;
        }
        .simpleDialog .content .title {
          display: flex;
          background: blue;
          color: white;
          padding: 10px;
          cursor: pointer;
        }
        .simpleDialog .content .conform {
          display: flex;
          justify-content: center;
          padding: 10px;
          background: blue;
        }
      </style>
    </head>
    <body>
      <div id="el">
        <button @click="clickButton()" style="margin-top: 30px;">
          点击-出现-弹窗
        </button>
        <simple-dialog :required-data="requiredDataOut">
          插槽一
          <simple-dialog :required-data="requiredDataMid">
            插槽二
            <simple-dialog :required-data="requiredDataIn">
              插槽三
            </simple-dialog>
          </simple-dialog>
        </simple-dialog>
      </div>
      <script>
        Vue.component('simple-dialog', {
          template: `
            <div>
              <div class="simpleDialog" v-show="requiredData.isShow">
                <div class="mask" v-show="requiredData.isShow"></div>
                <div class="content" v-show="requiredData.isShow">
                  <div class="title">
                    <span>系统消息</span>
                  </div>
                  <div :style="{width:requiredData.width||'800px',height:requiredData.height||'400px'}">
                    <slot></slot>
                  </div>
                  <div class="conform">
                    <button v-on:click="close()">关闭</button>
                    <button v-on:click="open()">打开</button>
                  </div>
                <div>
              </div>
            </div>
          `,
          props: {      
            requiredData: {
              type: Object
            }
          },
          data: function() {
            return {}
          },
          methods: {
            close: function () {
              this.requiredData.isShow = false;
              if(this.requiredData.closeFn) this.requiredData.closeFn();
            },
            open: function () {
              if(this.requiredData.openFn) this.requiredData.openFn();
            }                              
          }
        });
        new Vue({
          el: '#el',
          data(){
            var that = this;
            return {
              requiredDataOut : {
                isShow : false,
                width : '900px',
                height : '600px',
                openFn : function () {
                  that.requiredDataMid.isShow = true;
                }
              },
              requiredDataMid : {
                isShow : false,
                width : '600px',
                height : '400px',
                openFn : function () {
                  that.requiredDataIn.isShow = true;
                },
              },
              requiredDataIn : {
                isShow : false,
                width : '300px',
                height : '200px',
              },
              //一层弹窗可以如下使用
              //requiredDataOut = {
              //  isShow : false,
              //};
              //clickButton() {
              //  requiredDataOut.isShow = true;
              //};
            }
          },
          methods:{
            clickButton() {
              this.requiredDataOut.isShow = true;
            }
          }
        })
      </script>
    </body>
  </html>
2、可拖拽(含插槽)
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8">
      <title>Vue多层弹窗</title>
      <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
      <script>
        function drag(wholeTitleId, wholeContentId) {
          var wholeTitleId = wholeTitleId||'titleId';
          var wholeContentId = wholeContentId||'contentId';
          var oDiv = document.getElementById(wholeContentId);
          if(!oDiv) return;
          oDiv.onmousedown = down;
          function processThis(fn, nowThis) {
            return function (event) {
              fn.call(nowThis, event);
            };
          }
          function down(event) {
            event = event || window.event;
            if (event.target.id != wholeTitleId) return;
            this.initOffsetLeft = this.offsetLeft;
            this.initOffsetTop = this.offsetTop;
            this.initClientX = event.clientX;
            this.initClientY = event.clientY;
            this.maxOffsetWidth =
              (document.documentElement.clientWidth || document.body.clientWidth) -
              this.offsetWidth;
            this.maxOffsetHeight =
              (document.documentElement.clientHeight ||
                document.body.clientHeight) - this.offsetHeight;
            if (this.setCapture) {
              this.setCapture();
              this.onmousemove = processThis(move, this);
              this.onmouseup = processThis(up, this);
            } else {
              document.onmousemove = processThis(move, this);
              document.onmouseup = processThis(up, this);
            }
          }
          function move(event) {
            var nowLeft = this.initOffsetLeft + (event.clientX - this.initClientX);
            var nowTop = this.initOffsetTop + (event.clientY - this.initClientY);
            this.style.left = nowLeft + 'px';
            this.style.top = nowTop + 'px';
          }
          function up() {
            if (this.releaseCapture) {
              this.releaseCapture();
              this.onmousemove = null;
              this.onmouseup = null;
            } else {
              document.onmousemove = null;
              document.onmouseup = null;
            }
          }
        };
      </script>
      <style>
        .simpleDialog {
          position: fixed;
          width: 100%;
          height: 100%;
          top: 0;
          left: 0;
          display: flex;
          justify-content: center;
          align-items: center;
        }
        .simpleDialog .mask {
          position: fixed;
          width: 100%;
          height: 100%;
          top: 0;
          left: 0;
          background: black;
          opacity: 0.5;
        }
        .simpleDialog .content {
          position: fixed;
          background: white;
          opacity: 1;
          display: flex;
          flex-direction: column;
        }
        .simpleDialog .content .title {
          display: flex;
          background: blue;
          color: white;
          padding: 10px;
          cursor: pointer;
        }
        .simpleDialog .content .conform {
          display: flex;
          justify-content: center;
          padding: 10px;
          background: blue;
        }
      </style>
    </head>
    <body>
      <div id="el">
        <button @click="clickButton()" style="margin-top: 30px;">
          点击-出现-弹窗
        </button>
        <simple-dialog :required-data="requiredDataOut">
          插槽一
          <simple-dialog :required-data="requiredDataMid">
            插槽二
            <simple-dialog :required-data="requiredDataIn">
              插槽三
            </simple-dialog>
          </simple-dialog>
        </simple-dialog>
      </div>
      <script>
        Vue.component('simple-dialog', {
          template: `
            <div>
              <div class="simpleDialog" v-show="requiredData.isShow">
                <div class="mask" v-show="requiredData.isShow"></div>
                <div class="content" v-show="requiredData.isShow" :id="requiredData.contentId||'contentId'">
                  <div class="title" :id="requiredData.titleId||'titleId'">
                    <span>系统消息</span>
                  </div>
                  <div :style="{width:requiredData.width||'800px',height:requiredData.height||'400px'}">
                    <slot></slot>
                  </div>
                  <div class="conform">
                    <button v-on:click="close()">关闭</button>
                    <button v-on:click="open()">打开</button>
                  </div>
                <div>
              </div>
            </div>
          `,
          props: {      
            requiredData: {
              type: Object
            }
          },
          data: function() {
            return {}
          },
          methods: {
            close: function () {
              this.requiredData.isShow = false;
              if(this.requiredData.closeFn) this.requiredData.closeFn();
              var content = this.requiredData.contentId;
              document.getElementById(content).style.cssText = "position:fixed;display:flex;";
            },
            open: function () {
              if(this.requiredData.openFn) this.requiredData.openFn();
            }                              
          },
        });
        new Vue({
          el: '#el',
          data(){
            var that = this;
            return {
              requiredDataOut : {
                isShow : false,
                width : '900px',
                height : '600px',
                titleId : "titleId1",
                contentId : "contentId1",
                openFn : function () {
                  that.requiredDataMid.isShow = true;
                  drag(that.requiredDataMid.titleId,that.requiredDataMid.contentId);
                }
              },
              requiredDataMid : {
                isShow : false,
                width : '600px',
                height : '400px',
                titleId : "titleId2",
                contentId : "contentId2",
                openFn : function () {
                  that.requiredDataIn.isShow = true;
                  drag(that.requiredDataIn.titleId,that.requiredDataIn.contentId);
                },
              },
              requiredDataIn : {
                isShow : false,
                width : '300px',
                height : '200px',
                titleId : "titleId3",
                contentId : "contentId3",
              },
              //一层弹窗可以如下使用
              //requiredDataOut = {
              //  isShow : false,
              //};
              //clickButton() {
              //  requiredDataOut.isShow = true;
              //};
            }
          },
          methods:{
            clickButton() {
              this.requiredDataOut.isShow = true;
              drag(this.requiredDataOut.titleId,this.requiredDataOut.contentId);
            }
          }
        })
      </script>
    </body>
  </html>
 
四、2种三层弹窗组件之el-dialog
  <!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
    <title>vue2.6.10组件之el-dialog多层弹窗</title>
    <script src="https://cdn.bootcss.com/vue/2.6.10/vue.min.js"></script>
    <script src="https://cdn.bootcss.com/element-ui/2.10.1/index.js"></script>
    <link href="https://cdn.bootcss.com/element-ui/2.10.1/theme-chalk/index.css" rel="stylesheet">
    <style>
      #app{
        display: flex;
        justify-content: space-between;
      }
    </style>
  </head>
  <body>
    <div id="app">
      <el-button type="text" @click="outerVisible = true">点击打开外层弹窗</el-button>
      <el-dialog
        width="70%"
        title="外层"
        :visible.sync="outerVisible"
      > 
        以下是外层弹窗的插槽<br/>
        <div style="height:200px;border:1px solid #ccc;padding:20px;">
          width="70%",决定弹-窗的宽<br/>
          style="height:200px",通过给弹-窗插槽的部分标签设置高,决定弹-窗的高,如此框<br/>
          :visible.sync="middleVisible",传递引用,此处改变,别处也改变<br/>
          :visible="middleVisible",传递普通值,此处改变,别处不改变<br/>
          <el-dialog
            width="50%"
            title="中层"
            :visible.sync="middleVisible"
            append-to-body
          >
            以下是中层弹-窗的插槽<br/>
            <el-dialog
              width="30%"
              title="内层"
              :visible.sync="innerVisible"
              append-to-body
            >
              以下是内层弹-窗的插槽<br/>
              <div slot="footer" class="dialog-footer">
                <el-button @click="innerVisible = false">关闭内层</el-button>
                <el-button type="primary" @click="innerVisible = false">关闭内层</el-button>
              </div>
              以上是内层弹-窗的插槽<br/>
            </el-dialog>
            <div slot="footer" class="dialog-footer">
              <el-button @click="middleVisible = false">关闭中层</el-button>
              <el-button type="primary" @click="innerVisible = true">打开内层</el-button>
            </div>
            以上是中层弹-窗的插槽<br/>
          </el-dialog>
        </div>
        <div slot="footer" class="dialog-footer">
          <el-button @click="outerVisible = false">关闭外层</el-button>
          <el-button type="primary" @click="middleVisible = true">打开中层</el-button>
        </div>
        以上是外层弹-窗的插槽(代码中,此句位于按钮下面)<br/>
      </el-dialog>
    </div>
  </body>
  <script>
    new Vue({
      el: '#app',
      data() {
        return {
          outerVisible: false,
          middleVisible: false,
          innerVisible: false
        };
      },
      methods: {
        
      },
      components: {
        
      },
    })
  </script>
  </html>

五、vue问题与解决
1、问题1,用插件vod-js-sdk-v6上传视频时
   来源,https://cloud.tencent.com/developer/ask/260695
  (1)现象,报错“Error: ugc upload | signature verify forbidden”
  (2)原因,
    A、前端向后台索要签名,后台用小权限“腾讯账户”向腾讯索要签名,
    B、腾讯给后台小权限签名,后台给前端小权限签名,前端将小权限签名发给腾讯,腾讯给前端报错
  (3)解决,让后台换用大权限“腾讯账户”
2、问题2
  (1)现象,不是第一页的最后一页为空白
  (2)原因,最后一页不是第一页,删除最后一页的唯一项,然后请求当前页数据
  (3)解决, 
		delModelTrainings({trainingId: row.trainingId}).then(() => {
			ElMessage.success('删除成功');
			var index = (page.value.pageNum - 1)*page.value.pageSize + 1;
			//当前页第1项的序号>每页条数(当前页不是第1页) && 当前页第1项的序号=数据总数(当前页是最后1页且只有1项)
			if(index>page.value.pageSize && index===page.value.count){
				page.value.pageNum--;
			}
			listData();
		})
3、问题3,
  (1)现象,:src 文件路径错误问题的解决方法
  (2)解决,
    <template>
      <img :src="item.url" alt="logo" />
    </template>
    import background from '@/assets/image/background.png'
    var collect = reactive([
      {
        url: background, //url: '/src/assets/images/logo.png' 注释这样的写法会出错
        teacher: "讲师:李老师",
      }
    ]);
4、问题4
  (1)报错信息:[VUE ERROR] Invalid default value for prop "slides": 
    Props with type Object/Array must use a factory function to return the default value
  (2)错误原因:对于数组和对象类型的prop,Vue官方从最初的版本就推荐使用工厂函数返回默认值,而不是直接赋值空数组[]或空对象{}
  (3)错误示例
    props: {
      sizeRadio1: {
        type: Array,
        default: []
      }
      sizeRadio2: {
        type: Object,
        default: {}
      }
    },
  (4)正确示例
    props: {
      sizeRadio1: {
        type: Array,
        default: function() {
          return []
        }
      }
      sizeRadio2: {
        type: Object,
        default: function() {
          return {}
        }
      }
    },
5、问题5,汉化(汉字、中文、配置)
  (1)方案一,在App.vue中
    <script >
      import { defineComponent } from 'vue'
      import { ElConfigProvider } from 'element-plus'
      import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
      export default defineComponent({
        components: {
          ElConfigProvider,
        },
        setup() {
          return {
            locale: zhCn,
          }
        },
      })
    </script>
    <template>
      <div style="height:100%">
        <el-config-provider :locale="locale">
          <router-view v-slot="{ Component }">
            <keep-alive>
              <component :is="Component" />
            </keep-alive>
          </router-view>
      </el-config-provider>
      </div>
    </template>
  (2)方案二,在main.js中
    import ElementPlus from 'element-plus'
    import zhCn from 'element-plus/es/locale/lang/zh-cn'
    app.use(ElementPlus, { locale: zhCn })
    app.mount('#app')
6、问题6,深度选择器(也叫穿透选择器)!!!
  (1)>>>(直接穿透),在Vue3中已废弃,
    A、适用场景:Vue2的scoped样式中,不支持大多数CSS预处理器(如Sass、Less、Stylus)
    B、示例:
      <style scoped>
        /* 穿透到子组件中类名为.child 的元素 */
        .parent >>> .child {
          color: red;
        }
      </style>
  (2)/deep/(通用穿透),在Vue3中已废弃,与>>>类似,但兼容性更好
    A、适用场景:Vue2的scoped样式,支持大多数CSS预处理器(如Sass、Less、Stylus)
    B、示例:
      <style scoped lang="scss">
        /* 穿透到深层的 .deep-element 元素 */
        .container /deep/ .deep-element {
          font-size: 16px;
        }
      </style>
  (3)::v-deep(Vue专用穿透),Vue官方推荐的深度选择器,兼容性最佳
    A、适用场景:Vue2.7+和Vue3,支持所有预处理器
    B、示例:
      <style scoped>
        /* Vue2.7+语法 */
        ::v-deep .child-component button {
          background: blue;
        }
        /* Vue3推荐语法 */
        :deep(.child-component) button {
          background: blue;
        }
      </style>

六、收缩展开
1、多行收缩为2行,点击则“用动画”展开
  (1)隐藏Outer,不能触发点击事件
  (2)获取初始数据;内层显示full区域,异步计算full区域的高度并存储、显示fold区域、隐藏full区域
  (3)显示Outer
  (4)缺少1、3步骤,页面会闪烁
  (5)点击箭头,内层“用动画”显示溢出区域,隐藏初始区域
    <template>
      <div :style="{visibility: isOuterShow ? 'visible':'hidden'}">
        <div v-for="(item,index) in allNote" :style="{'background':item.readStatus?'#eaeef5':'#f5f6f9'}" class="mynote" @click="clickNoteItem(item)"  :key="index">
          <div class="mynote-item">
            <div>{{item.title}}</div>
            <div>
              <span style="padding-right: 30px;">{{millsecondsToDate(item.receiveTime)}}</span> 
              <el-icon :style="{visibility: item.isShowArrow ? 'visible':'hidden'}"  class="cursor">
                <ArrowRight style="color: gray" v-show="item.isFoldShow" @click.stop="clickItemArrow(item)" />
                <ArrowDown style="color: gray" v-show="!item.isFoldShow" @click.stop="clickItemArrow(item)" />
              </el-icon>
            </div>
          </div>
          <div v-show="isFullShow" class="isFullShow">{{item.content}}</div>
          <div v-show="item.isFoldShow" class="fold_">{{item.content}}</div>
          <!-- 下面,from-height,to-height(过程伴随overflow:hidden,应当与style-height一致) -->
          <div v-show="!item.isFoldShow" 
              :class="!item.isFoldShow? 'divDown' + item.index:''" 
              :style="{'height':item.height + 'px','overflow':'hidden'}" 
          >{{item.content}}</div>
        </div>
      </div>
    </template>
    <script setup>
      var isOuterShow = ref(false);//防止闪烁
      var isFullShow = ref(false);//计算行高
      var clickItemArrow = function(item){
        item.isFoldShow = !item.isFoldShow;
      };
      var getData = function(){
        getAllNotes(data).then(function(result){
          isFullShow.value = true;
          allNote.length = 0;
          total.value = result.data.total;
          var list = result.data.list;
          for(var i = 0; i<list.length; i++){
            list[i].isShowArrow = false; 
            list[i].isFoldShow = false; 
            allNote.push(list[i])
          } 
          setTimeout(function(){
            var all = document.getElementsByClassName('isFullShow')
            for(var i = 0; i<all.length; i++){
              if(all[i].clientHeight > 50) {
                allNote[i].isShowArrow = true;
                allNote[i].isFoldShow = true;
                allNote[i].index = i;
                allNote[i].height = all[i].clientHeight;
                document.styleSheets[0].insertRule(
                  "@keyframes moveDown" + i +
                  "{" +
                    "from { height: 40px; }" +
                    "to { height: " + all[i].clientHeight + "px; }" +
                  "}"
                )
                document.styleSheets[0].insertRule(
                  ".divDown" + i + 
                  "{" +
                    "animation: moveDown" + i + " 2s ease 0s 1 normal;" + /* 非常重要:2s前面有空格 */
                  "}"
                )
              }
            }
            isFullShow.value = false;
            isOuterShow.value = true;
          });
        })
      };
      onMounted(function() {
        getData();
      })
    </script>
    <style lang="scss">
      .cursor{
        cursor: pointer;
      }
      .fold_{
        overflow: hidden;
        display: -webkit-box;
        -webkit-box-orient: vertical;
        -webkit-line-clamp: 2;
      }
    </style>

七、各种滚动
1、滚动滚轮,元素底部从视口底部出现
  (1)可演示
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>元素底部出现,触发事件</title>
        <style>
          .div{
            height: 500px;
          }
          #elementToTrack {
            height: 400px;
            background: gray;
          }
        </style>
      </head>
      <body>
        <div class="div"></div>
        <div class="div"></div>
        <div class="div"></div>
        <div id="elementToTrack"></div>
        <div class="div"></div>
      </body>
      <script>
        var element = document.getElementById('elementToTrack');
        window.onscroll = function() {
          var viewportHeight = window.innerHeight || document.documentElement.clientHeight;//获取视口高度
          var viewportBottom = window.scrollY + viewportHeight;//页面顶部到视口底部的距离
          var elementBottom = element.offsetTop + element.offsetHeight;//页面顶部到元素底部的距离
          if (elementBottom < viewportBottom) {//如果元素底部高于视口底部(元素完全进入视口)!!!
            console.log(viewportBottom, elementBottom);
          }
        };
      </script>
    </html>
  (2)真实项目
    需求:
      A、表格底部消失在页面下面,左右滚动条出现在页面底部;
      B、表格底部出现,滚动条的位置恢复为默认的表格底部
    //src/view/base/search-topical-table.vue;57--59;202--230;233
    watch(() => props, (newVal) => {
      setTimeout(() => {
        checkScroll()
      },100)
    }, { immediate: true, deep: true })
    var tableBody = null;
    var container = null;
    var isOver = ref(false)
    onMounted(() => {
      container = document.querySelector('.main-container');
      tableBody = document.querySelector('.el-table__body');
      container.addEventListener('scroll', checkScroll);
      checkScroll();
    })
    onUnmounted(() => {
      container.removeEventListener('scroll', checkScroll)
    })
    function checkScroll() {
      //获取目标元素的底部位置
      var tableBottom = tableBody.getBoundingClientRect().bottom;
      var documentBottom = document.body.clientHeight;
      //表格底部位于页面底部上方
      if ( tableBottom < documentBottom ) {
        isOver.value = true;
      } else {
        isOver.value = false;
      }
    } 
    <div class="search-topical-table" :class="{'search-topical-table-fixed':!isOver}">
      <el-table></el-table>
      <el-pagination/>
    </div>
    <style lang="scss">
      .search-topical-table-fixed{
        .el-table__body-wrapper .el-scrollbar__bar.is-horizontal{
          position: fixed;
          bottom: 10px;
          left:165px;
        }
      }
    </style>
  (3)真实项目
    需求:滚动条的位置由默认的表格底部改为页面底部
    <template>
      <div class="search-topical-table">
        <el-table></el-table>
        <el-pagination/>
      </div>
    </template>
    <style lang="scss">
      .search-topical-table{
        .el-table__body-wrapper .el-scrollbar__bar.is-horizontal{
          position: fixed;
          bottom: 10px;
          height: 10px;
        }
      }
    </style>   
2、触发按钮,“页面”交替回到顶部底部
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>页面滚动条回到顶部示例</title>
      <style>
        button {
          color: red;
          font-size: 50px;
          border-radius: 10px;
          position: fixed;
          bottom: 0;
          right: 0;
        }
        .black {
          background: #000;
          color: #fff;
        }
        .div {
          height: 2000px;
          background-color: gray;
        }
      </style>
    </head>
    <body>
      <button id="btn">顶部--底部</button>
      <div class="black">顶部</div>
      <div class="div"></div>
      <div class="black">底部</div>
    </body>
    <script>
      var y = 0 ;
      var btn = document.getElementById('btn');
      btn.addEventListener('click', function() {
        y = y == 0 ? 2000 : 0;
        window.scrollTo({
          left: 0,
          top: y,
          behavior: 'smooth'
        });
      });
    </script>
  </html>
3、触发按钮,“元素”交替回到顶部底部
  (1)演示示例
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
        <title>元素滚动条回到顶部示例</title>
        <style>
          button {
            color: red;
            font-size: 50px;
            border-radius: 10px;
            position: fixed;
            top: 50px;
            right: 50px;
          }
          #myDiv {
            height: 500px;
            overflow: auto;
          }
          .black {
            background: #000;
            color: #fff;
          }
          #myDiv .myDiv {
            height: 1000px;
            background: gray;
          }
        </style>
      </head>
      <body>
        <button onclick="clickBtn()">顶部--底部</button>
        <div id="myDiv">
          <div class="black">顶部</div>
          <div class="myDiv"></div>
          <div class="black">底部</div>
        </div>
      </body>
      <script>
        var y = 0 ;
        function clickBtn() {
          y = y == 0? 1000 : 0;
          var div = document.getElementById('myDiv');
          div.scrollTo(0, y);
        }
      </script>
    </html>
  (2)项目示例 
    .app-main {
      /*50 = navbar */
      width: 100%;
      padding-bottom: 85px;
      margin-bottom: 20px;
      box-sizing: border-box;
      position: relative;
        //以下是,新增内容
      overflow: auto;
    }
    method(params).then(res => {
      allTableData.value = res.data.rows
      page.value.pageNum = res.data.pageNum
      page.value.pageSize = res.data.pageSize
      page.value.count = res.data.recordCount
      loading.value = false
      //以下是,新增内容
      var height = window.innerHeight - 100
      var ele = document.getElementsByClassName('app-main')[0];
      ele.style.height = height + 'px'
      ele.scrollTo({ top: 0, behavior: 'smooth' })
    })
4、初始化时,“元素”滚动条回到底部
  (1)html
    <div class="training-result-code">
      <div class="training-result-title">
        训练日志
      </div>
      <div class="training-result-code_content1" ref="divShow">
        <div ref="divSonShow">
          {{ modelLogs }}
        </div>
      </div>
      <div class="training-result-code_content2" ref="divHide" >
        {{ modelLogs }}
      </div>
    </div>
    /* 以下弹窗中使用 */
    <el-dialog title="查看日志" v-model="dialogVisible" width="70%">
      <div class="record-detail-code">
        <div class="record-detail-code_content1" ref="divShow">
          <div ref="divSonShow">
            {{ modelLogs }}
            <div style="padding-bottom: 100px"></div>
          </div>
        </div>
        <div class="training-result-code_content2" ref="divHide" >
          {{ modelLogs }}
        </div>
      </div>
      <template #footer>
        <el-button type="primary" @click="dialogVisible = false">确 定</el-button>
      </template>
    </el-dialog>
  (2)css
    &-code{
      background-color: #fff;
      border-radius: 10px;
      height: 500px;
      padding: 30px;
      &_content1{
        background-color: #000;
        overflow: scroll;
        color: #fff;
        white-space: pre-wrap;
        height: 420px;
      }
      &_content2{
        background-color: #000;
        overflow: scroll;
        color: #fff;
        white-space: pre-wrap;
        visibility: hidden;
      }
    }
    /* 以下弹窗中使用 */
    .record-detail-code{
      background-color: #fff;
      border-radius: 10px;
      height: 500px;
      padding: 10px;
      overflow: hidden;
      &_content1{
        background-color: #000;
        overflow: scroll;
        color: #fff;
        white-space: pre-wrap;
        height: 490px;
      }
      &_content2{
        background-color: #000;
        overflow: hidden;
        color: #fff;
        white-space: pre-wrap;
        visibility: hidden;
      }
    }
  (3)js
    const modelLogs = ref('');
    const divShow = ref(null);
    const divSonShow = ref(null);
    const divHide = ref(null);
    const timerId = ref(null);
    const getLogs = function () {
      getModelTrainingLogs({trainingId: route.query.trainingId}).then(res => {
        modelLogs.value = res.data.runningLogs||'没有训练日志';
        setTimeout(() => {
          divSonShow.value.style.height = divHide.value.clientHeight + 100 + 'px';
          divShow.value.scrollTop = divHide.value.clientHeight ;
        });
      })
    }
    getLogs()
    var textAry = ['训练失败','训练完成'];
    if(textAry.indexOf(route.query.text) === -1){
      timerId.value = setInterval(() => {
        getLogs()
      }, 500);
    }	
    /* 以下弹窗中使用 */
    const modelLogs = ref('');
    const divShow = ref(null);
    const divSonShow = ref(null);
    const divHide = ref(null);
    const showRecord = function(row){
      dialogVisible.value = true;
      getModelTrainingLogs({trainingId: row.trainingId}).then(res => {
        modelLogs.value = res.data.runningLogs||'没有训练日志';
        setTimeout(() => {
          divSonShow.value.style.height = divHide.value.clientHeight + 'px';
          divShow.value.scrollTop = divHide.value.clientHeight*5 ;
        });
      })
    }

八、拖拽
1、真实案例(与el-checkbox-group联合使用,拖拽之勾选表格列表)
  <script setup>
    import draggable from 'vuedraggable'
    const checkList = ref([])
    const columns = ref([
        {
            "label": "序号",
            "prop": "processVersionSid",
            "canChange": false,
            "tip": "数据示例:2346"
        },
        {
            "label": "栏目代码",
            "prop": "columnCode",
            "canChange": false,
            "tip": "数据示例:J875023"
        },
        {
            "label": "详细情况说明",
            "prop": "reasonDetail",
            "canChange": true,
            "tip": "数据示例:数据示例示例示例"
        },
        {
            "label": "系统备案栏目首播时间",
            "prop": "planPremiereTime",
            "canChange": true,
            "tip": "数据示例:每周四 7:00 - 8:00"
        },
        {
            "label": "中心核对栏目首播信息",
            "prop": "checkPremiereTime",
            "canChange": true,
            "tip": "数据示例:每周四 7:00 - 8:00"
        },
        {
            "label": "栏目重播信息",
            "prop": "replayTime",
            "canChange": true,
            "tip": "数据示例:每周四 7:00 - 8:00"
        },
        {
            "label": "完成率",
            "prop": "completeRate",
            "canChange": true,
            "tip": "数据示例:98%"
        },
        {
            "label": "备注",
            "prop": "remark",
            "canChange": false,
            "tip": ""
        }
    ])
  </script>
  <template>
    <div class="column-select-warp">
      <el-checkbox-group v-model="checkList">
        <draggable
          v-model="columns"
          :force-fallback="true"
          chosen-class="chosen"
          draggable=".drag"
          animation="300"
          item-key="prop"
        ><!-- 
          draggable=".drag",拖拽项的class;
          :class="{'drag': element.canChange}",该项是否添加class  -->
          <template #item="{ element, index }">
            <div class="column-select-item" :class="{'drag': element.canChange}">
              <svg-icon iconClass="move" class="move-icon"></svg-icon>
              <el-checkbox :disabled="!element.canChange" :value="element.prop"  size="large" >
                <span>{{ element.label }}</span>
                <span class="column-select-item__tip">{{ element.tip }}</span>
              </el-checkbox>
            </div>
          </template>
        </draggable>
      </el-checkbox-group>
    </div>
  </template>
2、可运行案例(与table联合使用,可拖动表头栏和表体行)
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8" />
      <title>vue.draggable、表格、vue3联合示例</title>
      <meta name="viewport"
          content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, minimal-ui">
      <script src="https://www.itxst.com/package/vue3/vue.global.js"></script>
      <script src="https://www.itxst.com/package/sortable/Sortable.min.js"></script>
      <script src="https://www.itxst.com/package/vuedraggablenext/vuedraggable.umd.min.js"></script>
    </head>
    <body style="padding:10px;">
      <div id="app">
        <itxst-component></itxst-component>
      </div>
    </body>
    <script type="x-template" id="itxst">
      <div>教程,https://www.itxst.com/vue-draggable-next/tutorial.html</div>
      <div>案例,https://debug.itxst.com/js/zfzeny7f</div>
      <table class="tb">
        <thead>
          <draggable
            v-model="headers"
            animation="200"
            tag="tr"
            :item-key="(key) => key"
          >
            <template #item="{ element: header, index: index}">
              <th class="move">
                {{ header }}
              </th>
            </template>
          </draggable>
        </thead>
        <draggable
          :list="list"
          handle=".move"
          animation="300"
          @start="onStart"
          @end="onEnd"
          tag="tbody"
          item-key="name"
        >
          <template #item="{ element, index }">
            <tr>
              <td
                class="move"
                v-for="(header, index) in headers"
                :key="header"
              >
                {{ element[header] }}
              </td>
            </tr>
          </template>
        </draggable>
      </table>
    </script>
    <script>
      const app = {
        //注册draggable组件
        components: {
          'itxst-component': {
            template: "#itxst",
            components: {
              "draggable": window.vuedraggable,
            },
            data() {
              return {
                //列的名称
                headers: ["id", "name", "intro"],
                //需要拖拽的数据,拖拽后数据的顺序也会变化
                list: [
                  { name: "www.itxst.com", id: 0, intro: "慢吞吞的蜗牛" },
                  { name: "www.baidu.com", id: 1, intro: "中文搜索引擎" },
                  { name: "www.google.com", id: 3, intro: "安卓操作系统" },
                ],
              }
            },
            methods: {
              //开始拖拽事件
              onStart() {},
              //结束拖拽事件
              onEnd() {}
            }
          }
        },
      }
      Vue.createApp(app).mount('#app')
    </script>
    <style>
      .title {
        padding: 3px;
        font-size: 13px;
      }
      .itxst {
        width: 600px;
      }
      .move {
        cursor: move;
      }
      table.tb {
        color: #333;
        border: solid 1px #999;
        font-size: 13px;
        border-collapse: collapse;
        min-width: 500px;
        user-select: none;
      }
      table.tb th {
        background: rgb(168 173 217);
        border-width: 1px;
        padding: 8px;
        border-style: solid;
        border-color: #999;
        text-align: left;
      }
      table.tb th:nth-of-type(1) {
        text-align: center;
      }
      table.tb td {
        background: #d6c8c8;
        border-width: 1px;
        padding: 8px;
        border-style: solid;
        border-color: #999;
      }
      table.tb td:nth-of-type(1) {
        text-align: center;
      }
    </style>
  </html>
3、可运行案例(与vue3联合使用,无序列表)
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8" />
      <title>vue.draggable、无序列表、vue3联合示例</title>
      <meta name="viewport"
        content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, minimal-ui">
      <script src="https://www.itxst.com/package/vue3/vue.global.js"></script>
      <script src="https://www.itxst.com/package/sortable/Sortable.min.js"></script>
      <script src="https://www.itxst.com/package/vuedraggablenext/vuedraggable.umd.min.js"></script>
    </head>
    <body>
      <div id="app">
        <div class="itxst">
          <div class="group">
            <draggable 
              :list="modules.group1" group="group1"
              handle=".move" filter=".forbid" 
              @start="onStart" @end="onEnd" :move="onMove"
              ghost-class="ghost" chosen-class="chosenClass" animation="300"
              :force-fallback="true" :touch-start-threshold="50"  
              :fallback-class="true" :fallback-on-body="true" :fallback-tolerance="50"
            >
              <template #item="{ element, index }">
                <div :class="element.disabledMove ? 'forbid item' : 'item'">
                  <label class="move">{{ element.name }}</label>
                  <p v-show="element.disabledPark" style="color:red">此处不允许拖拽和停靠</p>
                  <p v-show="!element.disabledPark">内容......</p>
                </div>
              </template>
            </draggable>
          </div>
          <div class="group">
            <draggable
              :list="modules.group2" group="group1"
              handle=".move" filter=".forbid" 
              @start="onStart" @end="onEnd" :move="onMove"
              ghost-class="ghost" chosen-class="chosenClass" animation="300"
              :force-fallback="true" :touch-start-threshold="50"  
              :fallback-class="true" :fallback-on-body="true" :fallback-tolerance="50"
            >
              <template #item="{ element, index }">
                <div :class="element.disabledMove ? 'forbid item' : 'item'">
                  <label class="move">{{ element.name }}</label>
                  <p>内容....</p>
                </div>
              </template>
            </draggable>
          </div>
          <div class="group">
            <draggable
              :list="modules.group3" group="group1"
              handle=".move" filter=".forbid" 
              @start="onStart" @end="onEnd" :move="onMove"
              ghost-class="ghost" chosen-class="chosenClass" animation="300"
              :force-fallback="true" :touch-start-threshold="50"  
              :fallback-class="true" :fallback-on-body="true" :fallback-tolerance="50"
            >
              <template #item="{ element, index }">
                <div :class="element.disabledMove ? 'forbid item' : 'item'">
                  <label class="move">{{ element.name }}</label>
                  <p>内容....</p>
                </div>
              </template>
            </draggable>
          </div>
        </div>
        <div>
          <div>教程,https://www.itxst.com/vue-draggable-next/tutorial.html</div>
          <div>案例,https://debug.itxst.com/js/byamn2ja</div>
          <div>属性说明:</div>
          <div style="display: flex;">
            <pre>
              animation:拖动时过渡动画持续时间
              chosen-class:选中的样式
              disabled:是否禁用拖拽组件
              delay:鼠标按下多少秒之后可以拖拽元素
              drag-class:拖动元素的样式
              draggable:通过样式设置拖拽,:draggable=".item",样式类为item的元素才能被拖动
              fallback-class:克隆选中元素的样式到跟随鼠标的样式
              fallback-on-body:克隆的元素添加到文档的body中
              fallback-tolerance:按下鼠标移动多少个像素才能拖动元素
              filter:通过样式设置不拖拽,:filter=".unmover",设置了unmover样式的元素不允许拖动
              force-fallback:忽略HTML5的拖拽行为
              ghost-class:宿主样式
              group:相同的组名可以相互拖拽
              handle:通过样式设置拖拽,:handle=".mover",只有当鼠标在样式类为mover类的元素上才能触发拖动
            </pre>
            <pre>
              list:数据源
              scroll:有滚动区域是否允许拖拽
              scroll-fensitivity:距离滚动区域多远时,滚动滚动条
              scroll-fn:滚动回调函数
              scroll-speed:滚动速度
              sort:是否开启排序
              touch-start-threshold:鼠标按下移动多少px才能拖动元素交换间隔的大小,可以查看菜单对应的属性说明
              @start:开始拖拽事件
              :move:拖拽中事件,可以用来控制是否允许停靠
              @end:结束拖拽事件
              vue-draggable组件的disabled、filter、handle、draggable这几个属性,优先级从高到低的排序是什么 
              disabled > handle > draggable(也可配置在插槽中) > filter
            </pre>
          </div>
        </div>
      </div>
      <script>
        const app = {
          data() {
            return {
              modules: {
                group1: [
                  { name: "第1组-1", id: 1, disabledMove: true, disabledPark: true },
                  { name: "第1组-2", id: 2, disabledMove: false, disabledPark: false },
                  { name: "第1组-3", id: 3, disabledMove: false, disabledPark: false },
                ],
                group2: [
                  { name: "第2组-1", id: 5, disabledMove: false, disabledPark: false },
                  { name: "第2组-2", id: 6, disabledMove: false, disabledPark: false },
                  { name: "第2组-3", id: 7, disabledMove: false, disabledPark: false },
                ],
                group3: [
                  { name: "第3组-1", id: 8, disabledMove: false, disabledPark: false },
                  { name: "第3组-2", id: 9, disabledMove: false, disabledPark: false },
                ],
              },
            }
          },
          //注册draggable组件
          components: {
            'draggable': window.vuedraggable
          },
          methods: {
            //拖拽开始的事件
            onStart() {
              console.log("开始拖拽");
            },
            //拖拽结束的事件
            onEnd() {
              console.log("结束拖拽");
            },
            onMove(e) {
              //不允许停靠
              if (e.relatedContext.element.disabledPark == true) return false;
              return true;
            }
          }
        }
        Vue.createApp(app).mount('#app')
      </script>
      <style>
        body {
          padding: 0px;
          margin: 0px;
          background-color: #f1f1f1;
        }
        .itxst {
          background-color: #f1f1f1;
          display: flex;
          justify-content: space-between;
          align-content: space-around;
          padding: 20px;
        }
        .group {
          display: flex;
          flex-direction: column;
          justify-content: flex-start;
          align-content: center;
          width: 32%;
        }
        .item {
          border: solid 1px #ddd;
          padding: 0px;
          text-align: left;
          background-color: #fff;
          margin-bottom: 10px;
          display: flex;
          flex-direction: column;
          height: 100px;
          user-select: none;
        }
        .item>label {
          border-bottom: solid 1px #ddd;
          padding: 6px 10px;
          color: #333;
        }
        .item>label:hover {
          cursor: move;
        }
        .item>p {
          padding: 0 10px;
          color: #666;
        }
        .ghost {
          border: solid 1px rgb(19, 41, 239) !important;
        }
        .chosenClass {
          opacity: 1;
          border: solid 1px red;
        }
        .fallbackClass {
          background-color: aquamarine;
        }
      </style>
    </body>
  </html>
  
九、vue从虚拟DOM到真实DOM的过程!!!
附、说明:
  A、虚拟DOM是用JS对象模拟真实DOM结构的内存数据,它会占用浏览器的内存资源,且其Diff算法会消耗CPU资源
  B、但相比频繁操作真实DOM(触发大量重排重绘),虚拟DOM能显著降低浏览器的整体性能开销
1、定义虚拟DOM节点构造函数
  class VNode {
    constructor(tag, data = {}, children = [], text = null) {
      this.tag = tag; //标签名
      this.data = data; //属性数据(class、style、事件等)
      this.children = children; //子节点
      this.text = text; //文本内容
      this.key = data.key; //用于Diff的key
    }
  }
2、创建虚拟DOM树(设计图)
  const vdomTree = new VNode(
    'div',
    { id: 'app', class: 'container' },
    [
      new VNode('h1', { class: 'title' }, null, '虚拟DOM转真实DOM示例'),
      new VNode(
        'ul',
        { style: { 'list-style': 'none' } },  //Vue规范:style必须是对象
        [
          new VNode('li', { key: 1, class: 'item' }, null, '第一项'),
          new VNode('li', { key: 2, class: 'item' }, null, '第二项'),
          new VNode(
            'li',
            { key: 3 },
            [new VNode('span', { style: { color: 'blue' } }, null, '带子元素的项')]
          )
        ]
      ),
      new VNode(
        'button',
        { 
          class: 'btn',
          on: { click: () => alert('按钮被点击') }  //事件处理
        },
        null,
        '点击我'
      )
    ]
  );
  console.log(vdomTree);的执行结果如下
  { 
    tag: 'div',
    data: {
      id: 'app',
      class: 'container',
      key: undefined  //未指定key时为undefined
    },
    children: [
      //第一个子节点:h1标题
      {
        tag: 'h1',
        data: {
          class: 'title',
          key: undefined
        },
        children: null,
        text: '虚拟DOM转真实DOM示例',
        key: undefined
      },
      //第二个子节点:ul列表
      {
        tag: 'ul',
        data: {
          style:  { 'list-style': 'none' },
          key: undefined
        },
        children: [
          //ul的第一个子节点:li
          {
            tag: 'li',
            data: {
              key: 1,
              class: 'item'
            },
            children: null,
            text: '第一项',
            key: 1
          },
          //ul的第二个子节点:li
          {
            tag: 'li',
            data: {
              key: 2,
              class: 'item'
            },
            children: null,
            text: '第二项',
            key: 2
          },
          //ul的第三个子节点:包含span的li
          {
            tag: 'li',
            data: {
              key: 3
            },
            children: [
              //li的子节点:span
              {
                tag: 'span',
                data: {
                  style: { color: 'blue' },
                  key: undefined
                },
                children: null,
                text: '带子元素的项',
                key: undefined
              }
            ],
            text: null,
            key: 3
          }
        ],
        text: null,
        key: undefined
      },
      //第三个子节点:button按钮
      {
        tag: 'button',
        data: {
          class: 'btn',
          on: {
            click: () => alert('按钮被点击')  //绑定的点击事件
          },
          key: undefined
        },
        children: null,
        text: '点击我',
        key: undefined
      }
    ],
    text: null,
    key: undefined
  }
3、实现虚拟DOM转真实DOM的函数(按图施工)
  function createElement(vnode) {
    //处理文本节点
    if (vnode.text !== null) {
      return document.createTextNode(vnode.text);
    }
    //创建元素节点
    const el = document.createElement(vnode.tag);
    //处理属性
    if (vnode.data) {
      Object.keys(vnode.data).forEach(key => {
        if (key === 'class') {
          el.className = vnode.data.class;
        } 
        else if (key === 'style') {
          Object.assign(el.style, vnode.data.style);  //style必须是对象
        }
        else if (key === 'on' && vnode.data.on) {
          //绑定事件
          Object.keys(vnode.data.on).forEach(event => {
            el.addEventListener(event, vnode.data.on[event]);
          });
        }
        else if (key !== 'key') {  //key不渲染到真实DOM
          el.setAttribute(key, vnode.data[key]);
        }
      });
    }
    //递归处理子节点
    if (vnode.children && vnode.children.length) {
      vnode.children.forEach(child => {
        el.appendChild(createElement(child));
      });
    }
    return el;
  }
4、转换为真实DOM并插入到页面
  //转换为真实DOM
  const realDOM = createElement(vdomTree);
  //插入到页面
  document.body.appendChild(realDOM);
  console.log('虚拟DOM转换完成,已插入页面');
5、最终渲染结果
  <div id="app" class="container">
    <h1 class="title">虚拟DOM转真实DOM示例</h1>
    <ul style="list-style: none;">
      <li class="item">第一项</li>
      <li class="item">第二项</li>
      <li>
        <span style="color: blue;">带子元素的项</span>
      </li>
    </ul>
    <button class="btn">点击我</button>
  </div>
  
十、自定义组件
 注、在Vue2、Vue3的单文件组件(SFC)模板中,支持驼峰命名和短横线分隔命名,
  A、import CctvEllipsis from '../base/cctv-ellipsis.vue',
  B、<CctvEllipsis />,
  C、<cctv-ellipsis />
1、自定义组件-图标svg-icon,来源,ai-web
  (1)配置
    A、package.json,引入依赖
      //npm i vite-plugin-svg-icons -D
      {
        "devDependencies": {
          "vite-plugin-svg-icons": "^2.0.1",
        }
      }
    B、vite.config.js,使用插件
      import createVitePlugins from './vite/plugins'
      export default defineConfig(
        ({command,mode})=>{
          plugins: [
            createVitePlugins(),
          ],
        }
      )
    C、createVitePlugins,存入插件
      import vue from '@vitejs/plugin-vue'
      import createSvgIcon from './svg-icon'
      export default function createVitePlugins(viteEnv, isBuild = false) {
        const vitePlugins = [vue()]//插件的存储容器
        vitePlugins.push(createSvgIcon(isBuild))
        return vitePlugins
      }
    D、createSvgIcon,生成插件
      import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
      import path from 'path'
      export default function createSvgIcon(isBuild) {
        return createSvgIconsPlugin({
          iconDirs: [path.resolve(process.cwd(), 'src/assets/icons/svg')],
          symbolId: 'icon-[dir]-[name]',//与iconName关联,注意iconName的前缀#
          svgoOptions: isBuild
        })
      }
    附、B,C,D步骤可以简化为本步骤
      来源,https://blog.csdn.net/weixin_53731501/article/details/125478380 
      //vite.config.js中配置,使用vite-plugin-svg-icons插件显示本地svg图标
      import path from 'path'
      import {createSvgIconsPlugin} from 'vite-plugin-svg-icons'
      export default defineConfig((command) => {
        return {
          plugins: [
            createSvgIconsPlugin({
              iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],// 指定要缓存的文件夹
              symbolId: '[name]'// 指定symbolId格式
            })
          ],
        }
      })
  (2)定义
    <template>
      <svg :class="svgClass">
        <use :xlink:href="iconName" :fill="color" />
      </svg>
    </template>
    <script>
      export default defineComponent({
        props: {
          nameIcon: {
            type: String,
            required: true
          },
          className: {
            type: String,
            default: ''
          },
          color: {
            type: String,
            default: ''
          },
        },
        setup(props) {
          return {
            iconName: computed(() => `#icon-${props.nameIcon}`),
            svgClass: computed(() => {
              if (props.className) {
                return `svg-icon ${props.className}`
              }
              return 'svg-icon'
            })
          }
        }
      })
    </script>
  (3)注入
    import { createApp } from 'vue'
    import App from './App.vue'
    import SvgIcon from '@/components/SvgIcon'
    const app = createApp(App)
    app.component('svg-icon', SvgIcon)//全局引入使用
    app.mount('#app')
  (4)使用,
    <svg-icon name-icon="clock" />,clock指向clock.svg
2、自定义组件-月季度半年全年,来源:bcbf-web
  (1)定义组件,many-date
    <script setup>
      import { 
        DArrowLeft,
        DArrowRight,
        Calendar,
      } from '@element-plus/icons-vue'
      const props = defineProps({
        showPeriod: ''
      });
      const emit = defineEmits(['dateOk'])
      const period = ref(''); //统计周期
      var date = new Date;
      var initMonth = date.getMonth();
      var initYear = date.getFullYear();
      var clickYear = ref(initYear);//被使用的年份
      var initItem = ref('');//当前所在的季节或半年
      var clickItem = ref('');//默认的或被点击的季节或半年
      var twoRef = ref('');
      var fourRef = ref('');
      var dateRange = ref('');
      var isShowTwo = ref(false);
      var isShowFour = ref(false);
      // 以下处理统计周期改变
      watch(() => props.showPeriod, (newValue, oldValue) => {
        if (newValue !== oldValue) {
          period.value = newValue;
          changePeriod(period.value)
          getInitItem()
          if (period.value === '月'|| period.value === '全年') {
            changeInput()
          }
        }
      }, { immediate: true, deep: true })
      function changePeriod(period) {
        if(period === '月'){
          if (initMonth == 0) {//如果现在处在今年一月,那么默认为去年十二月
            initMonth = 12;
            initYear--;
          }
          dateRange.value = [
            initYear + '-' + String(1).padStart(2, '0'), 
            initYear + '-' + String(initMonth).padStart(2, '0')
          ];//initMonth可以用于表示上个月
        }else if(period === '全年') {
          initYear--;
          dateRange.value = initYear.toString();
        }else if(period === '季度') {
          var four = {
            3: 'four',
            6: 'one',
            9: 'two',
            12: 'three',
          };
          for (var key in four) {
            if (initMonth < key) {
              if (key == 3) initYear--;//如果现在处在今年第一季度,那么默认为去年第四季度
              clickFourAndTwo(four[key]);
              break;
            }
          }
        }else if(period === '半年') {
          var two = {
            6: 'down',
            12: 'up',
          }
          for (var key in two) {
            if (initMonth < key) {
              if (key == 6) initYear--;//如果现在处在今年上半年,那么默认为去年下半年
              clickFourAndTwo(two[key]);
              break;
            }
          }
        }
      }
      function getInitItem() {//获取当前所在的季节或半年
        var type = clickItem.value;
        var four = ['one','two','three','four','one'];
        var two = ['up','down','up'];
        if(four.indexOf(type) > -1){
          initItem.value = four[four.indexOf(type) + 1]
        }
        if(two.indexOf(type) > -1){
          initItem.value = two[two.indexOf(type) + 1]
        }
      }
      function changeInput() {
        emit('dateOk', dateRange.value)
      }
      // 以下处理季度和半年
      function focusInput() {
        clickYear.value = initYear;
        if( period.value == '半年' ){
          isShowTwo.value = true;
          isShowFour.value = false;
          setTimeout(function(){
            twoRef.value.focus();
          }, 150);
        }
        if( period.value == '季度' ){
          isShowTwo.value = false;
          isShowFour.value = true;
          setTimeout(function(){
            fourRef.value.focus();
          }, 150);
        }
      }
      function clickArrow(type) {
        if(type === 'left') clickYear.value--;
        if(type === 'right') clickYear.value++;
      }
      function getBlur() {
        isShowTwo.value = false;
        isShowFour.value = false;
      }
      function clickFourAndTwo(type) {
        clickItem.value = type;
        var obj = {
          one: {
            start: clickYear.value + '-01',
            end: clickYear.value + '-03'
          },
          two: {
            start: clickYear.value + '-04',
            end: clickYear.value + '-06'
          },
          three: {
            start: clickYear.value + '-07',
            end: clickYear.value + '-09'
          },
          four: {
            start: clickYear.value + '-10',
            end: clickYear.value + '-12'
          },
          up: {
            start: clickYear.value + '-01',
            end: clickYear.value + '-06'
          },
          down: {
            start: clickYear.value + '-07',
            end: clickYear.value + '-12'
          },
        };
        var start = obj[type].start;
        var end = obj[type].end;
        var temp = addSpace(6) + start + addSpace(8) + '-' + addSpace(8) + end;
        isShowTwo.value = false;
        isShowFour.value = false;
        dateRange.value = temp; 
        emit('dateOk', [start, end])
      }
      function addSpace(num) { //添加空格
        var str = '\xa0';
        for(var i = 0; i < num; i++) str += '\xa0'
        return str;
      }
    </script>
    <template>
      <div style="width: 100%;">
        <!-- 以下调用已有组件,实现'月'和'全年'的功能 -->
        <el-date-picker 
          v-if="period === '月'" 
          v-model="dateRange" 
          type="monthrange" 
          :editable="false" 
          start-placeholder="开始日期"
          end-placeholder="结束日期" 
          style="width: 100%;" 
          format="YYYY-MM" 
          value-format="YYYY-MM"  
          @change="changeInput()" 
        />
        <el-date-picker 
          v-if="period === '全年'" 
          v-model="dateRange" 
          type="year" 
          :editable="false" 
          start-placeholder="开始日期"
          end-placeholder="结束日期" 
          style="width: 100%;" 
          format="YYYY" 
          value-format="YYYY"  
          @change="changeInput()" 
        />
        <!-- 以下加工已有组件,实现'季度'和'半年'的功能 -->
        <el-input 
          v-model="dateRange" 
          :disabled="isDisabled" 
          :readonly="true" 
          @focus="focusInput()" 
          v-if="period === '季度'|| period === '半年'"
        >
          <template #prefix>
            <el-icon><Calendar /></el-icon>
          </template>
        </el-input>
        <div class="many-date" tabindex="-1" v-if="isShowFour"  ref="fourRef" @blur="getBlur">
          <div class="many-date-up">
            <el-icon class="many-date-up-content" @click="clickArrow('left')" ><DArrowLeft /></el-icon>
            <div class="many-date-up-content" >{{ clickYear }}</div>
            <el-icon class="many-date-up-content" @click="clickArrow('right')"><DArrowRight /></el-icon>
          </div>
          <el-divider class="many-date-divider"/>
          <div class="many-date-down">
            <div class="many-date-down-content" @click="clickFourAndTwo('one')" 
              :class="[
                (initYear==clickYear-1 && initItem=='one')?'many-date-down-content-bgNow':null,
                (initYear==clickYear && clickItem=='one')?'many-date-down-content-bgClick':null,
              ]"
            >第一季度</div>
            <div class="many-date-down-content" @click="clickFourAndTwo('two')" 
              :class="[
                (initYear==clickYear && initItem=='two')?'many-date-down-content-bgNow':null,
                (initYear==clickYear && clickItem=='two')?'many-date-down-content-bgClick':null,
              ]"
            >第二季度</div>
          </div>
          <div class="many-date-down">
            <div class="many-date-down-content" @click="clickFourAndTwo('three')" 
              :class="[
                (initYear==clickYear && initItem=='three')?'many-date-down-content-bgNow':null,
                (initYear==clickYear && clickItem=='three')?'many-date-down-content-bgClick':null,
              ]"
            >第三季度</div>
            <div class="many-date-down-content" @click="clickFourAndTwo('four')" 
              :class="[
                (initYear==clickYear && initItem=='four')?'many-date-down-content-bgNow':null,
                (initYear==clickYear && clickItem=='four')?'many-date-down-content-bgClick':null,
              ]"
            >第四季度</div>
          </div>
        </div>
        <div class="many-date" tabindex="-1" v-if="isShowTwo"  ref="twoRef" @blur="getBlur">
          <div class="many-date-up">
            <el-icon class="many-date-up-content" @click="clickArrow('left')"><DArrowLeft /></el-icon>
            <div class="many-date-up-content" >{{ clickYear }}</div>
            <el-icon class="many-date-up-content" @click="clickArrow('right')"><DArrowRight /></el-icon>
          </div>
          <el-divider class="many-date-divider"/>
          <div class="many-date-down">
            <div class="many-date-down-content" @click="clickFourAndTwo('up')"  
              :class="[
                (initYear==clickYear-1 && initItem=='up')?'many-date-down-content-bgNow':null,
                (initYear==clickYear && clickItem=='up')?'many-date-down-content-bgClick':null,
              ]"
            >上半年</div>
            <div class="many-date-down-content" @click="clickFourAndTwo('down')"  
              :class="[
                (initYear==clickYear && initItem=='down')?'many-date-down-content-bgNow':null,
                (initYear==clickYear && clickItem=='down')?'many-date-down-content-bgClick':null,
              ]"
            >下半年</div>
          </div>
        </div>
      </div>
    </template>
    <style lang="scss">
      .many-date{
        width: 60%; 
        margin-left: 20%;
        border-radius: 4px;
        margin-top: 10px;
        position: absolute;
        z-index: 100;
        background: var(--el-bg-color-overlay);
        box-shadow: var(--el-box-shadow-light);
        &-up {
          display: flex;
          justify-content: space-between;
          align-items: center;
          padding: 10px 10% ;
          &-content {
            cursor: pointer;
            user-select: none;
            color: var(--el-text-color-regular);
          }
          &-content:hover {
            color: var(--el-color-primary);
          }
        }
        &-divider {
          margin: 0 10%; 
          width: 80%
        }
        &-down {
          display: flex; 
          justify-content: space-around;
          align-items: center;
          padding: 10px 10% ;
          &-content {
            padding: 4px 20px ;
            cursor: pointer;
            user-select: none;
            color: var(--el-text-color-regular);
            &-bgClick {
              background: #2162db;
              border-radius: 18px;
              color: #ffffff !important;
              font-weight: bolder;
            }
            &-bgNow {
              color: #2162db;
              font-weight: bolder;
            }
          }
          &-content:hover {
            color: var(--el-color-primary);
          }
        }
      }
    </style>
  (2)使用组件
    <script setup>
      const ruleShowForm = ref(false)
      const operateText = ref('')
      const ruleVersionForm = reactive({
        period: '',
        myDate: [],
      })
      const rules = {
        period: [{ required: true, trigger: "blur", message: "统计周期不能为空" }],
        myDate: [{ required: true, trigger: "blur", message: "日期范围不能为空" }],
      };
      const formRef = ref(null)
      const showDialogRule = () => {
        ruleShowForm.value = true;
        operateText.value = '创建新版本'
        formRef.value.clearValidate(); 
      }
      const createOrEdit = () => {
        formRef.value.validate((valid) => {
          if (valid) {
            var data = {
              period: ruleVersionForm.period,
              myDate: ruleVersionForm.myDate,
            }
            updateVersion(data).then((res) => {
              ruleShowForm.value = false;
              getList();
            }).catch(()=>{
              ruleShowForm.value = false;
              ElMessage("出错了");
            });
          } else {
            ElMessage.error('表单验证失败');
          }
        });
      }
      function handleDateOk(data) {
        console.log( data );
        ruleVersionForm.myDate = data
        formRef.value.validateField('myDate');
      }
      function clearSelect(key) {
        formRef.value.clearValidate(key);
      }
      function changePeriod() {
        formRef.value.clearValidate('myDate');
      }
      function validateSelect(key) {
        formRef.value.validateField(key);
      }
    </script>
    <template>
      <div class="content-table">
        <div class="content-table-header" style="padding-left: 0;">
            <el-button type="primary" @click="showDialogRule()">创建新版本</el-button>
        </div>
        <el-dialog v-model="ruleShowForm" :title="operateText"  width="600">
          <el-form :model="ruleVersionForm" class="rebut-form" ref="formRef" :rules="rules" label-width="80px">
            <el-form-item label="统计周期" prop="period">
              <el-select 
                v-model="ruleVersionForm.period" 
                placeholder="请选择统计周期" 
                @focus="clearSelect('period')" 
                @change="changePeriod"
                @blur="validateSelect('period')"
              >
                <el-option label="月" value="月" />
                <el-option label="季度" value="季度" />
                <el-option label="半年" value="半年" />
                <el-option label="全年" value="全年" />
              </el-select>
            </el-form-item>
            <el-form-item 
              label="日期范围" 
              prop="myDate" 
              v-if="ruleVersionForm.period"
            >
              <many-date :showPeriod="ruleVersionForm.period" @dateOk="handleDateOk" />
            </el-form-item>
          </el-form>
          <template #footer>
            <div class="dialog-footer">
              <el-button @click="ruleShowForm = false">取消</el-button>
              <el-button type="primary" @click="createOrEdit">确定</el-button>
            </div>
          </template>
        </el-dialog>
      </div>
    </template>
3、自定义组件-26小时-半成品,可演示,来源:bcbf-web
  (1)定义组件
    <template>
      <div class="timeInputBox" :id="'t1-'+id" :ref="'ref-'+id">
        <div style="display: flex;">
          <el-input ref="timeInput" :id="'t2-'+id" @focus="focus" v-model="input" :placeholder="placeholder"   class="centered-input"/>
          <span style="padding: 0 50px;">至</span>
          <el-input ref="timeInput" :id="'t2-'+id" @focus="focus" v-model="input" :placeholder="placeholder"   class="centered-input"/>
        </div>
        <div :class="{ menuBox: true, activemenuBox: isShowOption }" :id="'t3-'+id">
          <div class="triangle" :id="'t4-'+id"></div>
          <div class="menuMain" :id="'t5-'+id" style="display: flex">
            <div class="two">
              <div class="container" :id="'t6-'+id" ref="startHour">
                <div class="timeItem" :id="'t7-'+id" :style="{ height:(clientHeight+7)+'px' }" v-for="item in [1, 2]"
                  :key="item+'-1'" />
                <div @click="clickTime('startHour', index)" :ref="'timeItem'+index" v-for="(num, index) in timeList.hour"
                  :key="index+'01'" :class="{ activeTime: index == selectHourIndex }" :id="'t8-'+id">{{ textbuil(num) }}
                </div>
                <div class="timeItem" :id="'t9-'+id" :style="{ height:(clientHeight+7)+'px' }" v-for="item in [1, 2]"
                  :key="item+'-2'" />
              </div>
              <div class="container" :id="'t10-'+id" ref="startMinute">
                <div class="timeItem" :id="'t11-'+id" :style="{ height:(clientHeight+7)+'px' }" v-for="item in [1, 2]"
                  :key="item+'-3'" />
                <div :id="'t12-'+id" @click="clickTime('startMinute', index)" v-for="(num, index) in timeList.minute"
                  :key="index+'02'" :class="{ activeTime: index == selectMinuteIndex }">{{ num }}
                </div>
                <div class="timeItem" :id="'t13-'+id" :style="{ height:(clientHeight+7)+'px' }" v-for="item in [1, 2]"
                  :key="item+'-4'" />
              </div>
              <div class="container" :id="'t30-'+id" ref="startSecond">
                <div class="timeItem" :id="'t31-'+id" :style="{ height:(clientHeight+7)+'px' }" v-for="item in [1, 2]"
                  :key="item+'-3'" />
                <div :id="'t32-'+id" @click="clickTime('startSecond', index)" v-for="(num, index) in timeList.second"
                  :key="index+'02'" :class="{ activeTime: index == selectSecondIndex }">{{ num }}
                </div>
                <div class="timeItem" :id="'t33-'+id" :style="{ height:(clientHeight+7)+'px' }" v-for="item in [1, 2]"
                  :key="item+'-4'" />
              </div>
              <div class="botBut" :id="'t4-'+id">
              </div>
              <div class="line" :id="'t17-'+id" :style="{ top: `calc(50% - ${clientHeight - 5}px)` }"></div>
              <div class="line" :id="'t18-'+id" :style="{ top: `calc(50% + ${5}px)` }"></div>
            </div>
            <div style="width:10%">至</div>
            <div class="two">
              <div class="container" :id="'t6-'+id" ref="overHour">
                <div class="timeItem" :id="'t7-'+id" :style="{ height:(clientHeight+7)+'px' }" v-for="item in [1, 2]"
                  :key="item+'-1'" />
                <div @click="clickTime('overHour', index)" :ref="'timeItem'+index" v-for="(num, index) in timeList.hour"
                  :key="index+'01'" :class="{ activeTime: index == selectHourIndex }" :id="'t8-'+id">{{ textbuil(num) }}
                </div>
                <div class="timeItem" :id="'t9-'+id" :style="{ height:(clientHeight+7)+'px' }" v-for="item in [1, 2]"
                  :key="item+'-2'" />
              </div>
              <div class="container" :id="'t10-'+id" ref="overMinute">
                <div class="timeItem" :id="'t11-'+id" :style="{ height:(clientHeight+7)+'px' }" v-for="item in [1, 2]"
                  :key="item+'-3'" />
                <div :id="'t12-'+id" @click="clickTime('overMinute', index)" v-for="(num, index) in timeList.minute"
                  :key="index+'02'" :class="{ activeTime: index == selectMinuteIndex }">{{ num }}
                </div>
                <div class="timeItem" :id="'t13-'+id" :style="{ height:(clientHeight+7)+'px' }" v-for="item in [1, 2]"
                  :key="item+'-4'" />
              </div>
              <div class="container" :id="'t30-'+id" ref="overSecond">
                <div class="timeItem" :id="'t31-'+id" :style="{ height:(clientHeight+7)+'px' }" v-for="item in [1, 2]"
                  :key="item+'-3'" />
                <div :id="'t32-'+id" @click="clickTime('overSecond', index)" v-for="(num, index) in timeList.second"
                  :key="index+'02'" :class="{ activeTime: index == selectSecondIndex }">{{ num }}
                </div>
                <div class="timeItem" :id="'t33-'+id" :style="{ height:(clientHeight+7)+'px' }" v-for="item in [1, 2]"
                  :key="item+'-4'" />
              </div>
              <div class="botBut" :id="'t4-'+id">
                <el-button class="elbutton" :id="'t15-'+id" type="text" @click="confirmTime()">确定</el-button>
                <el-button class="elbutton" :id="'t16-'+id" style="color: #333;" type="text"
                  @click="cancelTime()">取消</el-button>
              </div>
              <div class="line" :id="'t17-'+id" :style="{ top: `calc(50% - ${clientHeight - 5}px)` }"></div>
              <div class="line" :id="'t18-'+id" :style="{ top: `calc(50% + ${5}px)` }"></div>
            </div>
          </div>
        </div>
      </div>
    </template>
    <script>
      export default {
        name: "timePicker",
        components: {},
        props: {
          id: { //多个时必传且不能相同
            type: String,
            default: '',
          },
          time: { //格式,10:30
            type: String,
            default: '00:00:00',
          },
          placeholder: {
            type: String,
            default: '请选择时间',
          },
          timeList: { //hour,24时=>次日00时,25时=>次日01时,以此类推
            type: Object,
            default: () => {
              return {
                hour: ["00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"],
                minute: ["00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59"],
                second: ["00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59"],
              }
            }
          }
        },
        data() {
          return {
            startTime: '',
            overTime: '',
            input: '',
            isMounted: true,
            isShowOption: false,
            clientHeight: 36,
            selectHourIndex: -1,
            selectMinuteIndex: -1,
            selectSecondIndex: -1,
            setTimeing: false,
          };
        },
        mounted() {
          // 点击页面其他地方关闭时间选择组件
          document.addEventListener('click', (e) => {
            const ids = e.target.id?.split("-")[1]
            if (this.id != ids) {
              this.isShowOption = false
            }
          })
          // 监听时/分/秒滚动
          this.$refs.startHour.addEventListener('scroll', () => {
            const scrollTop = this.$refs.startHour.scrollTop
            const index =this.selectHourIndex = Math.round(scrollTop / this.clientHeight); // 加粗选中数字
            this.input = this.textbuil(this.timeList.hour[this.selectHourIndex])+':'+this.timeList.minute[this.selectMinuteIndex]+':'+this.timeList.second[this.selectSecondIndex]
          })
          this.$refs.startMinute.addEventListener('scroll', () => {
            const scrollTop = this.$refs.startMinute.scrollTop;
            const index =this.selectMinuteIndex = Math.round(scrollTop / this.clientHeight); // 加粗选中数字
            this.input = this.textbuil(this.timeList.hour[this.selectHourIndex])+':'+this.timeList.minute[this.selectMinuteIndex]+':'+this.timeList.second[this.selectSecondIndex]
          })
          this.$refs.startSecond.addEventListener('scroll', () => {
            const scrollTop = this.$refs.startSecond.scrollTop;
            const index =this.selectSecondIndex = Math.round(scrollTop / this.clientHeight); // 加粗选中数字
            this.input = this.textbuil(this.timeList.hour[this.selectHourIndex])+':'+this.timeList.minute[this.selectMinuteIndex]+':'+this.timeList.second[this.selectSecondIndex]
          })
          // 监听时/分/秒滚动
          this.$refs.overHour.addEventListener('scroll', () => {
            const scrollTop = this.$refs.overHour.scrollTop
            const index =this.selectHourIndex = Math.round(scrollTop / this.clientHeight); // 加粗选中数字
            this.input = this.textbuil(this.timeList.hour[this.selectHourIndex])+':'+this.timeList.minute[this.selectMinuteIndex]+':'+this.timeList.second[this.selectSecondIndex]
          })
          this.$refs.overMinute.addEventListener('scroll', () => {
            const scrollTop = this.$refs.overMinute.scrollTop;
            const index =this.selectMinuteIndex = Math.round(scrollTop / this.clientHeight); // 加粗选中数字
            this.input = this.textbuil(this.timeList.hour[this.selectHourIndex])+':'+this.timeList.minute[this.selectMinuteIndex]+':'+this.timeList.second[this.selectSecondIndex]
          })
          this.$refs.overSecond.addEventListener('scroll', () => {
            const scrollTop = this.$refs.overSecond.scrollTop;
            const index =this.selectSecondIndex = Math.round(scrollTop / this.clientHeight); // 加粗选中数字
            this.input = this.textbuil(this.timeList.hour[this.selectHourIndex])+':'+this.timeList.minute[this.selectMinuteIndex]+':'+this.timeList.second[this.selectSecondIndex]
          })
          this.setInput()
        },
        methods: {
          focus() {
            this.isShowOption = true
            this.$emit('focus')
          },
          clickTime(type, index) {
            this.$refs[type].scrollTop = index * this.clientHeight;
          },
          confirmTime() {
            if (this.setTimeing) return
            this.setTimeing = true
            setTimeout(() => {
              this.setTimeing = false
            }, 100);
            // 以上阻止连续点击
            this.startTime = this.timeList.hour[this.selectHourIndex]+':'+this.timeList.minute[this.selectMinuteIndex]+':'+this.timeList.second[this.selectSecondIndex]
            this.startTime = this.timeList.hour[this.selectHourIndex]+':'+this.timeList.minute[this.selectMinuteIndex]+':'+this.timeList.second[this.selectSecondIndex]
            this.$emit('sendTime', this.startTime);
            this.isShowOption = false
          },
          cancelTime() {
            this.$emit('sendTime', this.startTime);
            this.isShowOption = false
          },
          textbuil(data) {
            /*
            if (data > 23) {
              data = data * 1
              return '次日0'+(data - 24)
            } */
            return data
          },
          setInput() {
            const [hourDistance, minuteDistance, secondDistance] = this.time.split(':')
            this.startTime = this.input = this.textbuil(hourDistance)+':'+minuteDistance+':'+secondDistance
          },
        },
        watch: {
          isShowOption: {
            handler: function (newVal) {
              if (newVal) {
                var num = this.isMounted? 8:0;
                this.isMounted = false;
                this.clientHeight = this.$refs.timeItem0[0].clientHeight;
                const [hourDistance = 0, minuteDistance = 0, secondDistance = 0] = this.startTime.split(':');
                this.$refs.startHour.scrollTop = hourDistance * this.clientHeight + num;
                this.$refs.startMinute.scrollTop = minuteDistance * this.clientHeight + num;
                this.$refs.startSecond.scrollTop = secondDistance * this.clientHeight + num;
                /////////////////////////////////////////////
                this.$refs.overHour.scrollTop = hourDistance * this.clientHeight + num;
                this.$refs.overMinute.scrollTop = minuteDistance * this.clientHeight + num;
                this.$refs.overSecond.scrollTop = secondDistance * this.clientHeight + num;
              }
            }
          },
        },
      };
    </script>
    <style lang="scss">
      .timeInputBox {
        position: relative;
        .menuBox {
          position: absolute;
          width: 500px;
          height: 230px;
          z-index: 9;
          transition: .25s;
          overflow: hidden;
          transform: scaleY(0);
          transform-origin: top;
          box-shadow: 10px 9px 20px -10px #e1dfdf;
          .triangle {
            width: 0;
            height: 0;
            border-top: 6px solid transparent;
            border-bottom: 6px solid #fff;
            border-left: 6px solid transparent;
            border-right: 6px solid transparent;
            margin-left: 10px;
            margin-bottom: -1px;
            z-index: 10;
            position: absolute;
            top: 0;
            left: 10px;
          }
          .menuMain {
            background-color: #fff;
            height: calc(100% - 10px);
            width: 100%;
            border-radius: 4px;
            border: 1px solid #eaeaea;
            z-index: 9;
            margin-top: 10px;
            box-shadow: 2px 1px 20px -11px #333;
            display: flex;
            .two {
              width: 40%;
              .container {
                float: left;
                height: calc(100% - 36px);
                width: 33%;
                overflow: auto;
                text-align: center;
                font-size: 12px;
                color: #606266;
                cursor: pointer;
                :hover {
                  background-color: #4d7fff16;
                }
                .timeItem:hover {
                  background-color: transparent;
                }
                .activeTime {
                  font-weight: bold;
                  color: #000;
                  &:hover {
                    background-color: transparent;
                  }
                }
              }
              .botBut {
                width: 50%;
                height: 36px;
                border-top: 1px solid #d7d7d7;
                position: absolute;
                bottom: 2px;
                background-color: #ffffff;
                .elbutton {
                  font-size: 12px;
                  float: right;
                  margin: auto 10px;
                  font-weight: 500;
                }
              }
              .line {
                position: absolute;
                height: 1px;
                width: calc((100% - 40px));
                left: 20px;
                background-color: #e1e1e1;
                // border-top: 1px solid #e1e1e1;
                // border-bottom: 1px solid #e1e1e1;
              }
            }
          }
        }
        .activemenuBox {
          transform: scaleY(1);
        }
      }
      .container::-webkit-scrollbar {
        width: 7px;
        // height: 5px;
      }
      .container::-webkit-scrollbar-thumb {
        // background: linear-gradient(to bottom right, #4d7fff 0%, #1a56ff 100%);
        background-color: #dfdfdf;
        border-radius: 5px;
      }
      .container::-webkit-scrollbar-track {
        background-color: #fafafa;
        // border: 1px solid #ccc;
      }
      .container::-webkit-scrollbar-button {
        background-color: #fff;
        border-radius: 5px;
      }
      .container::-webkit-scrollbar-button:hover {
        background-color: #c1c1c1;
      }
      .centered-input .el-input__inner {
        text-align: center !important;
      }
    </style>
  (2)使用组件
    import TimePicker from "./timePicker";
    <el-form-item label="播出时间--1" >
      <TimePicker :id="'32'" :time="'20:34:58'" />
    </el-form-item>
4、自定义组件-水印组件Water1mark,来源:bcbf-web 
  附、图片加载后,只要在表单中输入文字,改变字体大小、颜色,图片的水印立即发生改变!!!
  附、添加水印组件的实现逻辑
    A、监听props.url变化,
      a、new Image并监听其下载事件,给该实例的url赋值。在下载事件里,
      b、给canvas标签设置宽高(会清空画布),
      c、用标签的drawImage方法把实例绘制成图片,用标签的各种方法绘制初始水印
    B、监听props.config的变化,
      a、给canvas标签设置宽高(会清空画布),
      b、用标签的drawImage方法把实例绘制成图片,用标签的各种方法绘制更新水印
  附、为什么vue3的有些逻辑写在watch里,而不写在updated里
    A、逻辑写在updated里,watch数据没有改变时,逻辑也会执行
  (1)定义组件
    <script setup>
      const props = defineProps({
        imgUrl: {
          type: String,
          default: ''
        },
        content:{
          type: String,
          default: ''
        }
      });
      const emit = defineEmits(['watermarkUpdate']);
      let maxHeight = ref(650)
      let maxWidth = ref(540)
      const config = reactive({
        content: '',
        font: {
          fontSize: 16,
          color: 'rgba(175, 175, 177, 0.5)',
        },
        rotate: -55,
        gap: [10, 5],
        offset: [10, 137],
      });
      const graph = ref(null);
      const canvas = ref(null);
      const img = ref(null);
      const loading = ref(false)
      const handleSave = () => {
        loading.value = true
        // 将canvas对象中的图像数据转换为base64编码的dataURL
        // 这种dataURL可以直接在HTML中使用,或者通过JavaScript发送给服务器进行保存或传输
        const imageData = canvas.value.toDataURL('image/png');
        emit('watermarkUpdate', imageData, config.content);
        loading.value = false
      };
      //以下,绘制图片
      const drawCanvas = () => {
        // 获取图片原始宽、高
        let w = img.value.width;
        let h = img.value.height;
        // 按图片宽、高比例“缩放”图片(可选步骤)
        const maxSize = 600;
        if (w > h) {
          // 宽大于高时,宽度缩放到maxSize,高度等比例缩放
          h = h * (maxSize / w);
          w = maxSize;
        } else {
          // 宽小于等于高时,高度缩放到maxSize,宽度等比例缩放
          w = w * (maxSize / h);
          h = maxSize;
        }
        // 设置Canvas尺寸(会清空画布)
        canvas.value.width = w;
        canvas.value.height = h;
        // 在Canvas上绘制图片(从左上角开始,覆盖整个Canvas)
        canvas.value.getContext('2d').drawImage(img.value, 0, 0, w, h);
        // 以下,与canvas尺寸相关!!!
          // 1、3种尺寸,图片尺寸、样式尺寸、绘制尺寸
          // 2、canvas默认标签尺寸,宽300px;高150px;坐标原点(0,0)位于画布左上角,向右是x轴正方向,向下是y轴正方向
          // 3、尺寸设置,js设置大于标签设置,同种设置时,最大尺寸大于尺寸
        // 以下,drawImage,在canvas的上下文中绘制图像,项目中最常使用5个参数,使用3、5、9个参数说明
          // 1、使用3个参数,drawImage(img,x,y)。不缩放,不裁切;可能不完整
          //   (1)img:必填,图像源。这可以是一个
          //     A、HTMLImageElement,创建方式为new Image()和document.getElementById("myImage")
          //     B、HTMLCanvasElement,创建方式为document.createElement("canvas")和document.getElementById("myCanvas")
          //     C、HTMLVideoElement,创建方式为document.createElement("video")和document.getElementById("myVideo")
          //   (2)x和y:必填,图像在画布上的起始坐标
          //     A、直接使用原图的自然宽高,即img.naturalWidth、img.naturalHeight
          //     B、如果原图比Canvas大,超出部分会被裁剪,不会自动缩放
          //     C、如果原图比Canvas小,剩余区域保持空白,不会自动拉伸
          // 2、使用5个参数,drawImage(img, x, y, width, height)。只缩放,不裁切;可能变形
          //   (1)图片会被“强制缩放”至指定的width和height尺寸
          //   (2)若width/height比例与原图不一致,图像会发生变形(非等比例缩放)
          //   (3)如需保持宽高比,需手动计算并设置合适的width或height值
          // 3、使用9个参数,drawImage(img,sx,sy,sWidth,sHeight,dx,dy,dWidth,dHeight)。既缩放,又裁切;可能变形,可能不完整
          //   (1)前4个参数(sx,sy,sWidth,sHeight)用于裁切原图 
          //     A、从原图的(sx,sy)坐标开始,截取宽为sWidth、高为sHeight的区域
          //     B、超出原图范围的部分会被忽略
          //   (2)后4个参数(dx,dy,dWidth,dHeight)用于定义裁切区域在画布上的绘制位置和尺寸
          //     A、将裁切后的区域绘制到画布的(dx, dy)坐标处
          //     B、并缩放至宽dWidth、高dHeight的尺寸
          //   (3)若sWidth/sHeight与dWidth/dHeight的比例不一致
          //     A、裁切后的区域会被拉伸或压缩,可能导致图像变形
          //     B、如需保持比例,需手动计算两组尺寸的比例并保持一致
          //   (4)此模式同时实现了“裁切原图局部”和“缩放绘制”的功能,适用于雪碧图(精灵图)展示、图片局部截取等场景
      };
      //以下,绘制文字
      const drawText = () => {
        //去掉下面拦截后产生的问题是:无法删除图片上水印的最后一个文字!!!
        /* if(!config.content){//http://10.70.38.84/browse/YANSHOU-10133
          return 
        } */
        //以下,使用水印参数1/2
        let textCtx = canvas.value.getContext('2d');
        textCtx.fillStyle = config.font.color;
        textCtx.font = config.font.fontSize + 'px Arial' ;
        let baseWidth = textCtx.measureText('啊').width;
        const list =config.content? config.content.split(/[(\r\n)\r\n]+/):[]; //一次水印可能有多行
        let maxWidth = 0
        list.forEach((item,index) => {
          maxWidth = Math.max(maxWidth, item.length);
        });
        console.log(list)
        let xAdd = config.offset[0]*baseWidth;
        let yAdd = config.offset[1]*baseWidth - list.length*baseWidth;
        textCtx.save();
        textCtx.translate(xAdd, yAdd);
        textCtx.rotate(config.rotate * Math.PI / 180);
        list.forEach((item,index) => {
          textCtx.fillText(item, 0,  config.font.fontSize * index); 
        });
        textCtx.restore();
      };
      //以下,绘制图片、绘制文字!!!
      watch(() => props.imgUrl, () => { 
        if (props.imgUrl.length === 0) {
          return;
        }
        img.value = new Image;
        img.value.onload = function () {
          drawCanvas(); 
          //以下,生成水印参数2/2
          let w = img.value.width;
          let h = img.value.height;
          if (w > h) {
            config.font.fontSize =  parseInt(w/20) 
            config.offset[0] = parseInt( w/2/config.font.fontSize - 5 )
            config.offset[1] = parseInt( h/config.font.fontSize + 1 )  
          }else {
            config.font.fontSize =  parseInt(w/15) 
            config.offset[0] = parseInt( w/2/config.font.fontSize - 4.7 )
            config.offset[1] = parseInt( h/config.font.fontSize - 0.3  )
          }
          drawText(); 
          return canvas.value;
        };
        config.content = props.content
        img.value.src = props.imgUrl; //1.4、生成img图片
      }, { immediate: true });
      
      //以下,(再次)绘制图片、绘制文字
      watch(config, () => { 
        drawCanvas(); 
        drawText() 
      })
    </script>
    <template>
      <div class="Watermark__wrap">
        <div ref="graph" class="Watermark__image">
          <canvas ref="canvas" :style="{'max-height': maxHeight+'px','max-width':maxWidth+'px'}" ></canvas>
        </div>
        <el-form class="Watermark__form" :model="config" label-position="top" label-width="50px">
          <el-form-item label="水印信息">
            <el-input  type="textarea"  :rows="6"
              style="width: 590px"
              maxlength="50"
              placeholder="请输入水印信息"
              show-word-limit
              v-model="config.content" 
            />
          </el-form-item>
          <!-- YANSHOU-11161 -->
          <el-form-item label="颜色">
            <el-color-picker v-model="config.font.color" show-alpha />
          </el-form-item>
          <el-form-item label="字号">
            <el-slider v-model="config.font.fontSize" :max="500" />
          </el-form-item>
          <el-form-item label="旋转">
            <el-slider v-model="config.rotate" :min="-180" :max="180" />
          </el-form-item>
          <el-form-item label="偏移">
            <el-space>
              <el-input-number v-model="config.offset[0]" placeholder="左偏移" controls-position="right" />
              <el-input-number v-model="config.offset[1]" placeholder="上偏移" controls-position="right" />
            </el-space>
          </el-form-item>
          <el-form-item>
            <el-button type="primary" @click="handleSave" :loading="loading">确定</el-button>
          </el-form-item>
        </el-form>
      </div>
    </template>
    <style lang="scss">
      .Watermark {
        &__wrap {
          display: flex;
          flex-direction: row;
          &__div{
            border: 1px solid lightgray ;
            border-radius: 6px;
            padding-left: 10px;
            margin-bottom: 10px;
            height: 32px;
            line-height: 32px;
          }
          &__bg{
            background-color: #F0F0F0;
            cursor: pointer;
          }
        }
        &__image {
          display: flex;
          align-items: center;
          justify-content: center;
          width: 558px;
          margin-right: 10px;
          height: 650px;
        }
      }
    </style>
  (2)使用组件
    <el-dialog v-model="showEditWatermark" width="1200">
      <Water1mark :imgUrl="editWatermarkImageUrl" v-if="showEditWatermark" :content="watermark" @watermarkUpdate="handleWatermarkUpdate"/>
    </el-dialog>
    const handleWatermarkUpdate = (imageURL, content) => {//提交水印图片
      showEditWatermark.value = false;
      applicationInfo.value.licenses[editWatermarkIndex.value].base64Image = imageURL;
      applicationCheckForm.value.licenses[editWatermarkIndex.value].watermark = content
      let file = {raw:dataURLtoFile(imageURL,applicationInfo.value.licenses[editWatermarkIndex.value].licenseName+'.png')}
      uploadFileTool(file,'license').then(res => {
        applicationCheckForm.value.licenses[editWatermarkIndex.value].fileId = res.data.fileInfoId;
      })
    }
5、自定义组件-水印组件Water2mark,来源:bcbf-web
  附、图片加载后,用固定文字,向图片添加循环“固定水印”,铺满图片,另向父组件提供下载功能!!!
  附、添加水印组件的实现逻辑,监听props.url变化
    A、new Image并监听其下载事件,给该实例的url赋值。在下载事件里
    B、给canvas标签设置宽高(会清空画布)
    C、用标签的drawImage方法把实例绘制成图片,用标签的各种方法绘制文字
  (1)定义组件
    <script setup>
      const props = defineProps({
        imgUrl: {//水印图片的url
          type: String,
          default: ''
        },
        content:{//水印文字
          type: String,
          default: ''
        },
        name:{//水印图片下载名称
          type: String,
          default: ''
        },
        size:{//水印图片大小
          type: Object,
          default: {}
        }
      });
      const emit = defineEmits(['watermarkUpdate']);
      let rate = 0.5;
      let maxHeight = ref( 650*rate )
      let maxWidth = ref( 540*rate )
      const graph = ref(null);
      const canvas = ref(null);
      const img = ref(null);
      //以下,绘制图片
      const drawCanvas = () => {
        // 重新设置canvas的宽高,会清空画布
        let w = img.value.width;
        let h = img.value.height;
        canvas.value.width = w;
        canvas.value.height = h;
        let hs=  maxHeight.value/h
        let ws = maxWidth.value/w
        if(hs>ws){
          maxHeight.value  = h*ws
          maxWidth.value = 540
        } else {
          maxWidth.value  = w*hs
          maxHeight.value  = 650
        }
        maxWidth.value = props.size.width||maxWidth.value
        maxHeight.value = props.size.height||maxHeight.value
        canvas.value.getContext('2d').drawImage(img.value, 0, 0, w, h);
      };
      //以下,绘制文字
      const drawText = () => {
        var ctx = canvas.value.getContext('2d');
        if (!ctx) return;
        ctx.save();
        var size = img.value.width > img.value.height ? img.value.width : img.value.height;
        var font = (size/30) +'px Arial'; //字体大小与图片大小绑定
        ctx.font = font;
        ctx.fillStyle = 'rgba(175, 175, 177, 0.5)';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        var radians = -45;
        var textWidth = ctx.measureText(props.content).width;
        var textHeight = parseInt(font, 10) || 20;
        var offsetX = textWidth - 150;
        var offsetY = textHeight + size/5; //y轴偏移量与图片大小绑定
        // 计算对角线距离以确保全覆盖
        var diagonal = Math.sqrt(canvas.value.width * canvas.value.width + canvas.value.height * canvas.value.height);
        // 计算需要的水印数量
        var cols = Math.ceil(diagonal / offsetX);
        var rows = Math.ceil(diagonal / offsetY);
        // 移动到画布中心(旋转后的中心)
        ctx.translate(canvas.value.width / 2, canvas.value.height / 2);
        // 绘制水印网格
        for (let i = -rows; i < rows; i++) { //重复绘制水印!!!
          for (let j = -cols; j < cols; j++) {
            ctx.save();
            // 移动到当前水印位置
            ctx.translate(j * offsetX, i * offsetY);
            // 旋转水印
            ctx.rotate(radians);
            // 绘制水印文字
            ctx.fillText(props.content, 0, 0);
            ctx.restore();
          }
        }
        // 恢复画布状态
        ctx.restore();
      };
      //以下,绘制图片、绘制文字!!!
      watch(() => props.imgUrl, () => {
        if (props.imgUrl.length === 0) {
          return;
        }
        img.value = new Image;
        img.value.onload = function () {
          drawCanvas();
          drawText();
          return canvas.value;
        };
        img.value.src = props.imgUrl;
      }, { immediate: true });
      //以下,下载图片!!!
      const downCanvas = () => {
        const link = document.createElement('a');
        link.download = (props.name || props.content || 'canvas' + '.png').replace(/\.\w+$/, '') + '.jpg';
        link.href = canvas.value.toDataURL('image/png');
        link.click();
      };
      defineExpose({
        downCanvas,
      });
    </script>
    <template>
      <div class="water2mark__wrap">
        <div ref="graph" class="water2mark__image">
          <canvas ref="canvas" :style="{'max-height': maxHeight+'px','max-width':maxWidth+'px'}" ></canvas>
        </div>
      </div>
    </template>
    <style lang="scss">
      .water2mark {
        &__wrap {
          display: flex;
          flex-direction: row;
          &__div{
            border: 1px solid lightgray;
            border-radius: 6px;
            padding-left: 10px;
            margin-bottom: 10px;
            height: 32px;
            line-height: 32px;
          }
          &__bg{
            background-color: #F0F0F0;
            cursor: pointer;
          }
        }
        &__image {
          display: flex;
          align-items: center;
          justify-content: center;
          margin-right: 10px;
        }
      }
    </style> 
  (2)使用组件
    <Water2mark 
      :ref="(el) => getRef(el, index)"
      :imgUrl="item.base64Image" 
      :content="watermarkText" 
      :name="'第'+(index+1)+'次频道换证证照'" 
      :size="sizeSmall" /> 
    <el-button type="primary" link @click="downImg(item, index)">下载证照</el-button>
    const itemRefs = ref([]); 
    const getRef = (el, index) => {
      itemRefs.value[index] = el;
    }
    function downImg(item, index) {
      itemRefs.value[index].downCanvas();
    };
6、自定义组件-给PDF文件添加水印,来源:nm-web。内容共约1060行
  附、PDF文件加载后,页面出现多张缩略图,在弹窗分页展示中,给选中的缩略图分别添加水印,暴露方法给父组件,供其获取所有的水印图片!!!
  附、添加水印组件的实现逻辑,监听props.url变化,用pdfjs-dist@3.11.174提供的方法展示并更新图片
  附、pdfjs-dist各版本发布时间与功能简介
    A、1.0.1040,2015-09,1.x正式版,奠定PDF.js核心功能框架(基础渲染、分页、文本选择),兼容IE9+等旧浏览器
    B、1.10.100,2018-05,1.x最后一个稳定版,冻结核心功能,仅保留后续安全补丁支持,兼容IE9+
    C、2.0.943,2019-07,2.x正式发布,重构渲染引擎,提升性能,支持更多PDF2.0标准特性,兼容IE11+
    D、2.16.105,2022-09,2.x最后一个维护版,保留IE11支持,后续版本(3.x+)放弃IE兼容,仅修复严重安全漏洞
    E、3.0.279,2022-01,3.x正式发布版,升级依赖库,重构部分核心模块,首次引入.mjs模块化文件(兼容保留.js)
    F、3.11.174,2023-05,3.x最后一个重要维护版,修复安全漏洞,优化PDF表单渲染兼容性
    G、4.0.189,2023-07,4.x系列初始测试后的迭代版,主要优化ES模块(.mjs)的加载稳定性
    H、4.10.38,2025-01,4.x偏向基础优化的版本,适配后续主流构建工具的兼容性调整
  (1)定义组件
    <template>
      <div class="pdf-thumbnail-container">
        <!-- 文件信息区域 -->
        <!-- <div class="file-section"> -->
          <!-- 文件信息 -->
          <!-- <div v-if="fileInfo.name" class="file-info">
            <p><strong>文件名:</strong>{{ fileInfo.name }}</p>
            <p><strong>总页数:</strong>{{ fileInfo.totalPages }}页</p>
          </div> -->
          <!-- 提示信息 -->
          <!-- <el-alert
            v-if="showPageNotice"
            title="提示:当前PDF超过10页,仅显示前10页"
            type="info"
            :closable="false"
            show-icon
            class="page-notice"
          /> -->
        <!-- </div> -->
        <!-- 缩略图区域 -->
        <div class="thumbnails-section">
          <div v-if="thumbnails.length === 0" class="empty-state">
            <el-empty description="请提供PDF文件URL" />
          </div>
          <div v-else class="thumbnails-grid">
            <div
              v-for="(thumbnail, index) in thumbnails"
              :key="index"
              class="thumbnail-item"
              :class="{ 
                selected: selectedPages.includes(thumbnail.pageNum),
                watermarked: hasWatermark(thumbnail.pageNum)
              }"
              @click="togglePageSelection(thumbnail.pageNum)"
            >
              <div class="thumbnail-image">
                <img :src="getThumbnailUrl(thumbnail.pageNum)" :alt="`第${thumbnail.pageNum}页`" />
                <!-- “预览”按钮在右上方 -->
                <div class="preview-btn top-right">
                  <el-button 
                    size="small" 
                    type="text" 
                    @click.stop="openPreviewDialog(thumbnail.pageNum)"
                  >
                    预览
                  </el-button>
                </div>
                <!-- “已加水印”标签在左下方 -->
                <!-- 
                <div v-if="hasWatermark(thumbnail.pageNum)" class="watermark-badge bottom-left">
                  <el-tag size="small" type="success">已加水印</el-tag>
                </div> -->
              </div>
              <div class="thumbnail-info">
                <el-checkbox
                  :model-value="selectedPages.includes(thumbnail.pageNum)"
                  @click.stop
                  @change="togglePageSelection(thumbnail.pageNum)"
                >
                  P{{ thumbnail.pageNum }}
                </el-checkbox>
              </div>
            </div>
          </div>
        </div>
        <!-- 大图预览弹窗 -->
        <el-dialog
          v-model="previewDialogVisible"
          :title="`第 ${currentPreviewPageNum} 页预览`"
          width="35%"
          top="5vh"
          :close-on-click-modal="true"
        >
          <div class="preview-dialog-content">
            <div class="large-preview-container">
              <div v-if="showLargePreviewLoading" class="loading-container">
                <el-icon class="loading-icon"><Loading /></el-icon>
                <span class="loading-text">大图加载中...</span>
              </div>
              <img
                v-else
                :src="currentLargePreviewImage"
                alt="大图预览"
                class="large-preview-image"
              />
              <!-- 
              <div v-if="hasWatermark(currentPreviewPageNum) && !showLargePreviewLoading" class="large-preview-watermark-badge">
                <el-tag type="success">已加水印</el-tag>
              </div> -->
            </div>
            <!-- 
            <div class="preview-info">
              <p><strong>页码:</strong>第 {{ currentPreviewPageNum }} 页</p>
              <p><strong>尺寸:</strong>{{ currentPreviewSize }}</p>
            </div> -->
          </div>
          <!-- 
          <template #footer>
            <div class="preview-dialog-footer">
              <el-button @click="previewDialogVisible = false">关闭</el-button>
              <el-button 
                type="primary" 
                @click="downloadPreviewImage"
                :loading="downloading"
              >
                下载图片
              </el-button>
            </div>
          </template> -->
        </el-dialog>
        <!-- 添加水印弹窗 -->
        <el-dialog
          v-model="watermarkDialogVisible"
          title=""
          width="50%"
          :close-on-click-modal="false"
        >
          <!-- 水印设置 -->
          <div class="watermark-dialog-content">
            <!-- 预览区域 -->
            <div class="preview-panel">
              <div class="preview-container">
                <div v-if="showPreviewLoading" class="loading-container">
                  <el-icon class="loading-icon"><Loading /></el-icon>
                  <span class="loading-text">图片加载中...</span>
                </div>
                <img
                  v-else
                  :src="currentPreviewImage"
                  alt="预览图"
                  class="preview-image"
                />
                <!-- 
                <div v-if="hasWatermark(currentPreviewPage) && !showPreviewLoading" class="preview-watermark-badge">
                  <el-tag type="warning">当前页面已有水印</el-tag>
                </div> -->
              </div>
              <div class="pagination">
                <el-button
                  :disabled="currentPreviewIndex === 0"
                  @click="showPrevPreview"
                  class="arrow-btn"
                  type="text"
                >
                  <el-icon><ArrowLeft /></el-icon>
                </el-button>
                <div class="page-counter">
                  {{ currentPreviewIndex + 1 }} <!-- / {{ selectedPages.length }} -->
                </div>
                <el-button
                  :disabled="currentPreviewIndex === selectedPages.length - 1"
                  @click="showNextPreview"
                  class="arrow-btn"
                  type="text"
                >
                  <el-icon><ArrowRight /></el-icon>
                </el-button>
              </div>
            </div>
            <!-- 设置区域 -->
            <div class="settings-panel">
              <el-form :model="watermarkSettings" label-width="100px" label-position="top">
                <el-form-item label="水印信息">
                  <el-input
                    v-model="watermarkSettings.text"
                    placeholder="请输入水印信息"
                    type="textarea"
                    :rows="4"
                    :maxlength="50"
                    show-word-limit 
                  />
                </el-form-item>
                <el-form-item label="颜色">
                  <el-color-picker v-model="watermarkSettings.color" show-alpha />
                </el-form-item>
                <el-form-item label="字号">
                  <el-slider v-model="watermarkSettings.fontSize" :min="5" :max="120" />
                </el-form-item>
                <el-form-item label="旋转">
                  <el-slider v-model="watermarkSettings.rotate" :min="-180" :max="180" />
                </el-form-item>
                <el-form-item label="行距">
                  <el-slider v-model="watermarkSettings.lineSpace" :min="6" :max="120" />
                </el-form-item>
                <el-form-item label="偏移">
                  <el-space>
                    <el-input-number v-model="watermarkSettings.left" placeholder="左偏移" controls-position="right" />
                    <el-input-number v-model="watermarkSettings.top" placeholder="上偏移" controls-position="right" />
                  </el-space>
                </el-form-item>
              </el-form>
              <div class="apply-btn">
                <el-button
                  type="primary"
                  @click="applyWatermarkToCurrentPage"
                >确定</el-button>
                <!-- 
                <el-button
                  type="danger"
                  @click="clearWatermarkFromCurrentPage"
                  :disabled="!hasWatermark(currentPreviewPage)"
                >清除水印</el-button> -->
              </div>
            </div>
          </div>
        </el-dialog>
      </div>
    </template>
    <script setup>
      import { ref, reactive, computed, onMounted, watch } from 'vue'
      import * as pdfjsLib from 'pdfjs-dist'
      import { ElMessage, ElMessageBox } from 'element-plus'
      import { ArrowLeft, ArrowRight, Loading } from '@element-plus/icons-vue'
      /* ==================== 1、配置pdf.js ==================== */
      pdfjsLib.GlobalWorkerOptions.workerSrc = import.meta.env.BASE_URL + 'pdfjs/pdf.worker.min.js'
      //配置pdf.js说明,我已经把下面(1)处的文件复制到(2)处,你只需拉下代码即可
      //目前使用的pdfjs-dist@3.11.174是本项目能运行的最高版本,不要试图使用此后的版本
      //(1)C:\Users\Haier\Desktop\nm-web\node_modules\pdfjs-dist\legacy\build\pdf.worker.min.js
      //(2)C:\Users\Haier\Desktop\nm-web\public\pdfjs\pdf.worker.min.js
      /* ==================== 2、定义全局变量与方法 ==================== */
      const largePreviewCanvases = ref(new Map()) //存储大图预览的canvas对象,key为页码,value为canvas对象
      const pageWatermarkSettings = ref(new Map()) //存储每个页面的水印配置,key为页码,value为水印配置对象
      const currPageCanvases = ref(new Map()) //存储每页的原始canvas对象,key为页码,value为canvas对象
      const pastPageImages = ref(new Map()) //存储每页的原始canvas对象,key为页码,value包含宽高和dataURL
      const watermarkedCanvases = ref(new Map()) //存储带水印的canvas对象,key为页码,value为已添加水印的canvas对象
      const watermarkedPages = ref(new Set()) //存储已添加水印的页面编号
      //保留默认配置,但不直接用于渲染
      const defaultWatermarkSettings = {
        text: '未来电视的PDF文件', //默认水印文字
        color: '#666666', //默认水印颜色
        fontSize: 30, //默认字体大小
        rotate: -30, //默认旋转角度
        lineSpace: 50, //默认行间距
        left: 0, //默认水平偏移
        top: 0 //默认垂直偏移
      }
      //更新watermarkSettings配置
      const watermarkSettings = reactive({
        text: '未来电视的PDF文件', //水印文字内容
        color: '#666666', //水印颜色
        fontSize: 30, //水印字体大小
        rotate: -30, //水印旋转角度
        lineSpace: 50, //水印行间距
        left: 0, //水印水平偏移量
        top: 0 //水印垂直偏移量
      })
      //应用水印到canvas的通用方法,支持指定页面配置
      const applyWatermarkToCanvas = (context, canvasWidth, canvasHeight, pageNum = null) => {
        //获取指定页面的水印配置,如果没有则使用当前watermarkSettings
        const settings = pageNum && pageWatermarkSettings.value.has(pageNum) 
          ? pageWatermarkSettings.value.get(pageNum)
          : watermarkSettings
        const { text, color, fontSize, rotate, lineSpace, left, top } = settings
        //设置水印样式
        context.fillStyle = color
        context.font = `${fontSize}px Arial`
        context.textAlign = 'center'
        context.textBaseline = 'middle'
        //测量文本宽度
        const textMetrics = context.measureText(text)
        const textWidth = textMetrics.width
        //计算合理的间距
        const horizontalGap = textWidth * 1.05
        const verticalGap = lineSpace
        //保存当前状态
        context.save()
        //应用偏移和旋转
        context.translate(canvasWidth / 2 + left, canvasHeight / 2 + top)
        context.rotate(rotate * Math.PI / 180)
        //绘制网格水印 - 使用计算出的合理间距
        for (let x = -canvasWidth; x < canvasWidth * 2; x += horizontalGap) {
          for (let y = -canvasHeight; y < canvasHeight * 2; y += verticalGap) {
            context.fillText(text, x, y)
          }
        }
        //恢复状态
        context.restore()
      }
      /* ==================== 3、页面初始化 ==================== */
      const props = defineProps({ //PDF的URL
        url: {
          type: String,
          required: true
        }
      })
      const fileInfo = reactive({
        name: '', //PDF文件名
        totalPages: 0 //PDF总页数
      })
      const thumbnails = ref([]) //缩略图列表,包含每页的缩略图数据
      const selectedPages = ref([]) //当前选中的页码数组
      const showPageNotice = computed(() => fileInfo.totalPages > 10) //是否显示页数提示(超过10页时显示)
      //加载PDF文档
      const loadPdfDocument = async () => {
        if (!props.url) return
        try {
          //从URL中提取文件名
          const fileName = props.url.split('/').pop() || 'document.pdf'
          fileInfo.name = fileName
          fileInfo.totalPages = 0
          //重置状态
          selectedPages.value = []
          currPageCanvases.value.clear()
          pastPageImages.value.clear()
          thumbnails.value = []
          //重置水印状态
          watermarkedPages.value.clear()
          watermarkedCanvases.value.clear()
          largePreviewCanvases.value.clear() //清除大图预览缓存
          //加载PDF文档
          const pdfDoc = await pdfjsLib.getDocument(props.url).promise
          fileInfo.totalPages = pdfDoc.numPages
          //生成缩略图(最多10页)
          const displayPages = Math.min(pdfDoc.numPages, 10)
          for (let pageNum = 1; pageNum <= displayPages; pageNum++) {
            await generateThumbnail(pdfDoc, pageNum)
          }
        } catch (error) {
          console.error('PDF处理错误:', error)
          ElMessage.error('PDF文件处理失败')
        }
      }
      //生成缩略图
      const generateThumbnail = async (pdfDoc, pageNum) => {
        try {
          //获取PDF页面
          const page = await pdfDoc.getPage(pageNum)
          const viewport = page.getViewport({ scale: 0.2 }) //缩略图缩放比例!!!
          //创建canvas
          const eleCanvas = document.createElement('canvas')
          const context = eleCanvas.getContext('2d') //获取2D绘图上下文
          eleCanvas.width = viewport.width
          eleCanvas.height = viewport.height
          //渲染页面(调用PDF的方法,把PDF图片渲染到canvas里)
          await page.render({
            canvasContext: context,
            viewport: viewport
          }).promise
          //保存原始canvas(用于水印处理)
          const currCanvas = document.createElement('canvas')
          currCanvas.width = eleCanvas.width
          currCanvas.height = eleCanvas.height
          currCanvas.getContext('2d').drawImage(eleCanvas, 0, 0)
          currPageCanvases.value.set(pageNum, currCanvas)
          //保存原始图片数据
          pastPageImages.value.set(pageNum, {
            width: eleCanvas.width,
            height: eleCanvas.height,
            dataUrl: eleCanvas.toDataURL()
          })
          //添加到缩略图列表
          thumbnails.value.push({
            pageNum,
            dataUrl: eleCanvas.toDataURL()
          })
        } catch (error) {
          console.error(`生成第${pageNum}页缩略图失败:`, error)
          ElMessage.error(`第${pageNum}页缩略图生成失败`)
        }
      }
      //点击缩略图
      const togglePageSelection = (pageNum) => {
        const index = selectedPages.value.indexOf(pageNum)
        if (index === -1) {
          selectedPages.value.push(pageNum)
        } else {
          selectedPages.value.splice(index, 1)
        }
      }
      /* ==================== 4、大图预览弹窗 ==================== */
      const previewDialogVisible = ref(false) //大图预览弹窗显示状态
      const currentPreviewIndex = ref(0) //当前预览图片在选中页面中的索引
      const currentPreviewPageNum = ref(1) //当前预览的页码
      const previewLoading = ref(false)
      const showPreviewLoading = computed(() => {
        return previewLoading.value || !currentPreviewImage.value
      })
      const currentPreviewPage = computed(() => selectedPages.value[currentPreviewIndex.value]) //当前预览的页码(从选中的页面中获取)
      const currentPreviewImage = computed(() => {
        //优先返回带水印的图片,如果没有则返回原始图片
        const watermarkedCanvas = watermarkedCanvases.value.get(currentPreviewPage.value)
        if (watermarkedCanvas) {
          return watermarkedCanvas.toDataURL() //返回带水印图片的dataURL
        }
        const canvas = currPageCanvases.value.get(currentPreviewPage.value)
        return canvas ? canvas.toDataURL() : '' //返回原始图片的dataURL或空字符串
      })
      //打开大图预览弹窗
      const openPreviewDialog = async (pageNum) => {
        currentPreviewPageNum.value = pageNum
        previewDialogVisible.value = true
        largePreviewLoading.value = true //开始加载
        //生成高质量大图预览
        await generateLargePreview(pageNum)
        largePreviewLoading.value = false //加载完成
      }
      //生成大图预览
      const generateLargePreview = async (pageNum) => {
        if (!props.url || largePreviewCanvases.value.has(pageNum)) {
          largePreviewLoading.value = false
          return
        }
        try {
          const pdfDoc = await pdfjsLib.getDocument(props.url).promise
          const page = await pdfDoc.getPage(pageNum)
          const viewport = page.getViewport({ scale: 2.0 }) //更高质量的大图预览
          const canvas = document.createElement('canvas')
          const context = canvas.getContext('2d')
          canvas.width = viewport.width
          canvas.height = viewport.height
          await page.render({
            canvasContext: context,
            viewport: viewport
          }).promise
          //如果该页面已有水印,应用水印到大图 - 使用该页面的特定配置
          if (hasWatermark(pageNum)) {
            applyWatermarkToCanvas(context, canvas.width, canvas.height, pageNum)
          }
          largePreviewCanvases.value.set(pageNum, canvas)
        } catch (error) {
          console.error('生成大图预览失败:', error)
          ElMessage.error('大图预览生成失败')
        }
      }
      //当前大图尺寸
      const currentPreviewSize = computed(() => {
        const canvas = largePreviewCanvases.value.get(currentPreviewPageNum.value)
        if (canvas) {
          return `${canvas.width} × ${canvas.height} 像素` //返回图片尺寸信息
        }
        return '未知尺寸' //默认返回未知尺寸
      })
      //当前大图图片
      const currentLargePreviewImage = computed(() => {
        const canvas = largePreviewCanvases.value.get(currentPreviewPageNum.value)
        if (canvas) {
          return canvas.toDataURL() //返回大图预览的dataURL
        }
        return getThumbnailUrl(currentPreviewPageNum.value) //如果没有大图,返回缩略图URL
      })
      //获取缩略图URL(优先返回带水印的缩略图)
      const getThumbnailUrl = (pageNum) => {
        const thumbnail = thumbnails.value.find(t => t.pageNum === pageNum)
        if (!thumbnail) return ''
        //如果有水印版本,返回水印版本
        if (hasWatermark(pageNum)) {
          const watermarkedCanvas = watermarkedCanvases.value.get(pageNum)
          if (watermarkedCanvas) {
            //创建缩略图尺寸的canvas
            const thumbCanvas = document.createElement('canvas')
            const pastImage = pastPageImages.value.get(pageNum)
            if (pastImage) {
              thumbCanvas.width = pastImage.width
              thumbCanvas.height = pastImage.height
              const context = thumbCanvas.getContext('2d')
              context.drawImage(
                watermarkedCanvas, 
                0, 0, watermarkedCanvas.width, watermarkedCanvas.height,
                0, 0, thumbCanvas.width, thumbCanvas.height
              )
              return thumbCanvas.toDataURL()
            }
          }
        }
        //返回原始缩略图
        return thumbnail.dataUrl
      }
      //检查页面是否有水印
      const hasWatermark = (pageNum) => {
        return watermarkedPages.value.has(pageNum)
      }
      //下载预览图片
      const downloading = ref(false) //下载状态,控制下载按钮的loading效果
      const largePreviewLoading = ref(false)
      const showLargePreviewLoading = computed(() => {
        return largePreviewLoading.value || !currentLargePreviewImage.value
      })
      const downloadPreviewImage = async () => {
        const canvas = largePreviewCanvases.value.get(currentPreviewPageNum.value)
        if (!canvas) {
          ElMessage.warning('暂无预览图片可下载')
          return
        }
        try {
          downloading.value = true
          const link = document.createElement('a')
          link.download = `PDF页面_${currentPreviewPageNum.value}.png`
          link.href = canvas.toDataURL('image/png')
          document.body.appendChild(link)
          link.click()
          document.body.removeChild(link) 
          ElMessage.success('图片下载成功')
        } catch (error) {
          console.error('下载图片失败:', error)
          ElMessage.error('图片下载失败')
        } finally {
          downloading.value = false
        }
      }
      /* ==================== 5、添加水印弹窗 ==================== */
      const watermarkDialogVisible = ref(false) 
      //打开水印弹窗
      const openWatermarkDialog = async () => {
        if (selectedPages.value.length === 0) return
        selectedPages.value.sort((a, b) => a - b)
        currentPreviewIndex.value = 0
        watermarkDialogVisible.value = true
        //为当前预览页面加载对应的水印配置
        const currentPageNum = selectedPages.value[currentPreviewIndex.value]
        if (pageWatermarkSettings.value.has(currentPageNum)) {
          //如果该页面已有水印配置,加载到当前设置中
          Object.assign(watermarkSettings, pageWatermarkSettings.value.get(currentPageNum))
        } else {
          //否则使用默认配置
          Object.assign(watermarkSettings, defaultWatermarkSettings)
        }
        //为预览生成高质量图片
        await generateHighQualityPreviews()
      }
      //生成高质量预览图(包含所有已选中PDF缩略图)
      const generateHighQualityPreviews = async () => {
        if (!props.url) return
        try {
          previewLoading.value = true //开始加载
          const pdfDoc = await pdfjsLib.getDocument(props.url).promise
          for (const pageNum of selectedPages.value) { //遍历所有已选中的PDF缩略图
            //如果已经有高质量图片,跳过
            if (currPageCanvases.value.get(pageNum)?.width > 800) continue
            const page = await pdfDoc.getPage(pageNum)
            const viewport = page.getViewport({ scale: 1.5 }) //提高预览质量!!!
            const canvas = document.createElement('canvas')
            const context = canvas.getContext('2d')
            canvas.width = viewport.width
            canvas.height = viewport.height
            await page.render({
              canvasContext: context,
              viewport: viewport
            }).promise
            currPageCanvases.value.set(pageNum, canvas)
            //如果该页面已有水印,重新应用水印到高质量图片
            if (hasWatermark(pageNum)) {
              await reapplyWatermarkToPage(pageNum, canvas)
            }
          }
        } catch (error) {
          console.error('生成高质量预览图失败:', error)
        } finally {
          previewLoading.value = false //加载完成
        }
      }
      //重新应用水印到页面(用于高质量预览)
      const reapplyWatermarkToPage = async (pageNum, currCanvas) => {
        try {
          const watermarkedCanvas = document.createElement('canvas')
          watermarkedCanvas.width = currCanvas.width
          watermarkedCanvas.height = currCanvas.height
          const context = watermarkedCanvas.getContext('2d')
          //先绘制原始内容
          context.drawImage(currCanvas, 0, 0)
          //应用水印 - 使用该页面的特定配置
          applyWatermarkToCanvas(context, watermarkedCanvas.width, watermarkedCanvas.height, pageNum)
          //更新存储
          watermarkedCanvases.value.set(pageNum, watermarkedCanvas)
        } catch (error) {
          console.error('重新应用水印失败:', error)
        }
      }
      //上一页预览
      const showPrevPreview = () => {
        if (currentPreviewIndex.value > 0) {
          currentPreviewIndex.value--
          updateWatermarkSettingsForCurrentPage()
        }
      }
      //下一页预览
      const showNextPreview = () => {
        if (currentPreviewIndex.value < selectedPages.value.length - 1) {
          currentPreviewIndex.value++
          updateWatermarkSettingsForCurrentPage()
        }
      }
      //更新当前页面的水印配置显示
      const updateWatermarkSettingsForCurrentPage = () => {
        const currentPageNum = selectedPages.value[currentPreviewIndex.value]
        if (pageWatermarkSettings.value.has(currentPageNum)) {
          //加载该页面的水印配置
          Object.assign(watermarkSettings, pageWatermarkSettings.value.get(currentPageNum))
        } else {
          //使用默认配置
          Object.assign(watermarkSettings, defaultWatermarkSettings)
        }
      }
      //应用水印到当前页
      const applyWatermarkToCurrentPage = async () => {
        const currentPageNum = selectedPages.value[currentPreviewIndex.value]
        try {
          //清除旧水印(如果存在)
          if (hasWatermark(currentPageNum)) {
            watermarkedPages.value.delete(currentPageNum)
            watermarkedCanvases.value.delete(currentPageNum)
            largePreviewCanvases.value.delete(currentPageNum) //清除大图预览缓存
          }
          //保存当前页面的水印配置
          pageWatermarkSettings.value.set(currentPageNum, {
            ...watermarkSettings
          })
          //重新生成高质量原始图片
          const currCanvas = await generateHighQualityPage(currentPageNum)
          if (!currCanvas) return
          //创建新canvas用于添加水印,保持高质量尺寸
          const watermarkedCanvas = document.createElement('canvas')
          watermarkedCanvas.width = currCanvas.width
          watermarkedCanvas.height = currCanvas.height
          const context = watermarkedCanvas.getContext('2d')
          //先绘制原始内容
          context.drawImage(currCanvas, 0, 0)
          //应用水印 - 使用当前页面的配置
          applyWatermarkToCanvas(context, watermarkedCanvas.width, watermarkedCanvas.height, currentPageNum)
          //更新存储 - 保持高质量尺寸
          watermarkedCanvases.value.set(currentPageNum, watermarkedCanvas)
          watermarkedPages.value.add(currentPageNum)
          //更新缩略图显示
          updateThumbnailDisplay(currentPageNum)
          ElMessage.success('水印添加成功')
        } catch (error) {
          console.error('添加水印失败:', error)
          ElMessage.error('水印添加失败')
        }
      }
      //生成高质量页面
      const generateHighQualityPage = async (pageNum) => {
        if (!props.url) return null
        try {
          const pdfDoc = await pdfjsLib.getDocument(props.url).promise
          const page = await pdfDoc.getPage(pageNum)
          const viewport = page.getViewport({ scale: 1.5 }) //使用高质量缩放
          const canvas = document.createElement('canvas')
          const context = canvas.getContext('2d')
          canvas.width = viewport.width
          canvas.height = viewport.height
          await page.render({
            canvasContext: context,
            viewport: viewport
          }).promise
          return canvas
        } catch (error) {
          console.error('生成高质量页面失败:', error)
          return null
        }
      }
      //清除当前页水印
      const clearWatermarkFromCurrentPage = async () => {
        const currentPageNum = selectedPages.value[currentPreviewIndex.value]
        try {
          await ElMessageBox.confirm(
            '确定要清除当前页面的水印吗?',
            '清除水印',
            {
              confirmButtonText: '确定',
              cancelButtonText: '取消',
              type: 'warning'
            }
          )
          watermarkedPages.value.delete(currentPageNum)
          watermarkedCanvases.value.delete(currentPageNum)
          largePreviewCanvases.value.delete(currentPageNum) //清除大图预览缓存
          pageWatermarkSettings.value.delete(currentPageNum) //清除该页面的水印配置
          //更新缩略图显示
          updateThumbnailDisplay(currentPageNum)
          ElMessage.success('水印清除成功')
        } catch (error) {
          if (error !== 'cancel') {
            console.error('清除水印失败:', error)
            ElMessage.error('水印清除失败')
          }
        }
      }
      //更新缩略图显示
      const updateThumbnailDisplay = (pageNum) => {
        const thumbnailIndex = thumbnails.value.findIndex(t => t.pageNum === pageNum)
        if (thumbnailIndex !== -1) {
          //触发重新渲染
          thumbnails.value = [...thumbnails.value]
        }
      }
      /* ==================== 6、暴露给父组件 ==================== */
      const selectedPagesCount = computed(() => selectedPages.value.length)
      //获取已选水印图片的数据
      const getWatermarkedImages = async (pageNumbers) => {
        const images = []
        for (const pageNum of pageNumbers) {
          const canvas = watermarkedCanvases.value.get(pageNum)
          if (canvas) {
            images.push({
              pageNum,
              dataUrl: canvas.toDataURL('image/png'),
              width: canvas.width,
              height: canvas.height
            })
          }
        }
        return images
      }
      //更新缩略图显示
      const getWatermarkedSelectedImages = () => {
        //找出既被选中又有水印的页面
        const targetPages = selectedPages.value.filter(page => watermarkedPages.value.has(page))
        const images = []
        for (const pageNum of targetPages) {
          const canvas = watermarkedCanvases.value.get(pageNum)
          if (canvas) {
            images.push({
              pageNum,
              dataUrl: canvas.toDataURL('image/png'),
              width: canvas.width,
              height: canvas.height,
              fileName: `已加水印_第${pageNum}页.png`
            })
          }
        }
        return images
      }
      //监听URL变化
      watch(() => props.url, (newUrl) => {
        if (newUrl) {
          loadPdfDocument()
        }
      })
      //组件挂载时加载PDF
      onMounted(() => {
        if (props.url) {
          loadPdfDocument()
        }
      })
      //暴露给父组件的方法和属性
      defineExpose({
        openWatermarkDialog,
        selectedPagesCount,
        getSelectedPages: () => selectedPages.value,
        getWatermarkedPages: () => Array.from(watermarkedPages.value),
        getWatermarkedImages ,
        getWatermarkedSelectedImages,
      })
    </script>
    <style scoped>
      .pdf-thumbnail-container {
        margin: 0 auto;
        padding: 20px;
        display: flex;
        flex-wrap: wrap;
        justify-content: space-between;
        flex-direction: column;
      }
      .file-section {
        margin-bottom: 30px;
        padding: 20px;
        background: #f8f9fa;
        border-radius: 8px;
      }
      .file-info {
        margin-top: 15px;
        padding: 15px;
        background: #e7f3ff;
        border-radius: 6px;
      }
      .file-info p {
        margin: 5px 0;
        color: #606266;
      }
      .page-notice {
        margin-top: 15px;
      }
      .thumbnails-section {
        margin: 20px 0;
      }
      .empty-state {
        text-align: center;
        padding: 40px 0;
      }
      .thumbnails-grid {
        display: grid;
        grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
        gap: 20px;
        margin-top: 20px;
      }
      .thumbnail-item {
        /* border: 2px solid #e4e7ed;
        border-radius: 8px; */
        cursor: pointer;
        transition: all 0.3s ease;
        position: relative;
      }
      .thumbnail-item:hover {
        border-color: #409eff;
        transform: translateY(-2px);
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
      }
      /* .thumbnail-item.selected { !!!
        border-color: #409eff;
        background: #f0f7ff;
      } */
      .thumbnail-item.watermarked {
        border-color: #67c23a;
      }
      .thumbnail-image {
        padding: 24px 12px 12px 12px;
        text-align: center;
        position: relative;
        background: white;
      }
      .thumbnail-image img {
        max-width: 100%;
        height: auto;
        /* 
        border-radius: 4px;
        border: 1px solid #e4e7ed; */
      }
      /* 已加水印标签移到左下方!!! */
      .watermark-badge.bottom-left {
        position: absolute;
        bottom: 5px;
        left: 5px;
      }
      /* 预览按钮在右上方!!! */
      .preview-btn.top-right {
        position: absolute;
        top: 2px;
        right: 2px;
      }
      .preview-btn.top-right button{
        color: #5321FF !important;
      }
      .thumbnail-info {
        text-align: center;
        margin-top: 8px;
      }
      .watermark-dialog-content {
        display: flex;
        gap: 30px;
        min-height: 600px;
      }
      .preview-panel {
        flex: 2;
        display: flex;
        flex-direction: column;
      }
      .preview-container {
        flex: 1;
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 20px;
        border: 1px solid #e4e7ed;
        border-radius: 8px;
        margin-bottom: 20px;
        background: #f8f9fa;
        max-height: 600px;
        position: relative;
      }
      .preview-image {
        max-width: 100%;
        max-height: 100%;
        width: auto;
        height: auto;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
        object-fit: contain;
      }
      .preview-watermark-badge {
        position: absolute;
        top: 10px;
        left: 10px;
      }
      .pagination {
        display: flex;
        justify-content: center;
        align-items: center;
        gap: 20px;
      }
      .arrow-btn {
        width: 40px;
        height: 40px;
        display: flex;
        align-items: center;
        justify-content: center;
        color: #606266;
      }
      .page-counter {
        font-size: 16px;
        font-weight: 600;
        color: #606266;
        min-width: 60px;
        text-align: center;
      }
      .settings-panel {
        width: 420px;
        display: flex;
        flex-direction: column;
      }
      .apply-btn {
        margin-top: auto;
        display: flex;
        justify-content: flex-start;
        gap: 10px;
      }
      /* 大图预览弹窗样式 */
      .preview-dialog-content {
        display: flex;
        flex-direction: column;
        gap: 20px;
      }
      .large-preview-container {
        display: flex;
        justify-content: center;
        align-items: center;
        background: #f8f9fa;
        border-radius: 8px;
        padding: 20px;
        min-height: 500px;
        position: relative;
      }
      .large-preview-image {
        max-width: 100%;
        max-height: 70vh;
        width: auto;
        height: auto;
        box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
        object-fit: contain;
      }
      .large-preview-watermark-badge {
        position: absolute;
        top: 20px;
        left: 20px;
      }
      .preview-info {
        padding: 15px;
        background: #e7f3ff;
        border-radius: 6px;
      }
      .preview-info p {
        margin: 5px 0;
        color: #606266;
      }
      .preview-dialog-footer {
        display: flex;
        justify-content: center;
        gap: 15px;
      }
      :deep(.el-dialog__body) {
        padding: 20px;
      }
      /* 优化弹窗样式 */
      :deep(.el-dialog) {
        max-width: 90%;
        margin-top: 5vh !important;
      }
      :deep(.el-dialog__header) {
        padding: 20px 20px 10px;
      }
      :deep(.el-dialog__title) {
        font-size: 18px;
        font-weight: bold;
      }
      /* 添加loading样式 */
      .loading-container {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        width: 100%;
        height: 100%;
        color: #909399;
      }
      .loading-icon {
        font-size: 40px;
        margin-bottom: 16px;
        animation: rotating 2s linear infinite;
      }
      .loading-text {
        font-size: 14px;
      }
      @keyframes rotating {
        from {
          transform: rotate(0deg);
        }
        to {
          transform: rotate(360deg);
        }
      }
      /* 确保预览容器有合适的高度 */
      .preview-container,
      .large-preview-container {
        min-height: 400px;
        display: flex;
        align-items: center;
        justify-content: center;
      }
    </style>
  (2)使用组件
    <el-row class="license-page-row">
      <div class="apply-detail-label">
        证照页码:
      </div>
      <div class="pdf-thumbnail-wrapper">
        <PdfThumbnail
          ref="pdfThumbnailRef"
          :url="pdfUrl"
        />
      </div>
    </el-row>
    // 以下水印相关
    import PdfThumbnail from './pdf.vue'
    const pdfUrl = ref('./749.pdf')
    // 获取子组件实例
    const pdfThumbnailRef = ref(null)
    const watermarkedImages = ref([])
    // 计算属性
    const hasSelectedPages = computed(() => {
      return pdfThumbnailRef.value?.selectedPagesCount > 0
    })
    const selectedPagesCount = computed(() => {
      return pdfThumbnailRef.value?.selectedPagesCount || 0
    })
    // 打开水印弹窗
    const handleAddWatermark = () => {
      if (pdfThumbnailRef.value) {
        pdfThumbnailRef.value.openWatermarkDialog()
      }
    }
    const hasWatermarkedSelected = computed(() => {
      return watermarkedSelectedCount.value > 0
    })
    const watermarkedSelectedCount = computed(() => {
      if (!pdfThumbnailRef.value) return 0
      const selectedPages = pdfThumbnailRef.value.getSelectedPages?.() || []
      const watermarkedPages = pdfThumbnailRef.value.getWatermarkedPages?.() || []
      // 找出既被选中又有水印的页面
      return selectedPages.filter(page => watermarkedPages.includes(page)).length
    })
    // 在父组件中调用子组件的方法
    const handleGetWatermarkedImages = () => {
      if (!pdfThumbnailRef.value) {
        ElMessage.warning('PDF组件未初始化')
        return
      }
      try {
        // 方法1:获取已选水印图片的数据
        const images = pdfThumbnailRef.value.getWatermarkedSelectedImages()
        // 方法2:获取基本信息
        // const info = pdfThumbnailRef.value.getWatermarkedSelectedInfo()
        // 方法3:直接下载
        // pdfThumbnailRef.value.downloadWatermarkedSelectedImages()
        if (images.length === 0) {
          ElMessage.warning('没有找到已加水印且勾选的图片')
          return
        }
        watermarkedImages.value = images
        console.log( watermarkedImages.value );
        ElMessage.success(`成功获取 ${images.length} 张已加水印且勾选的图片`)
      } catch (error) {
        console.error('获取水印图片失败:', error)
        ElMessage.error('获取水印图片失败')
      }
    }

  

posted @ 2020-10-26 23:40  WEB前端工程师_钱成  阅读(9146)  评论(0)    收藏  举报