فهرست منبع

Merge branch 'dev' of http://face3d.4dage.com:7005/chenzhiguang/qjkankan_v1.1.1 into dev

shaogen1995 2 سال پیش
والد
کامیت
a21dd5b1e9

+ 1 - 0
packages/qjkankan-editor/.env.testdev

@@ -1,3 +1,4 @@
+NODE_ENV=development
 VUE_APP_MAIN_COLOR=''
 VUE_APP_STATIC_DIR=static
 VUE_APP_CDN=https://ossxiaoan.4dage.com

+ 3 - 3
packages/qjkankan-editor/package.json

@@ -4,14 +4,14 @@
   "private": true,
   "scripts": {
     "serve": "vue-cli-service serve",
-    "serve-prod": "vue-cli-service serve --mode prod",
-    "serve-testprod": "vue-cli-service serve --mode testprod",
     "serve-testdev": "vue-cli-service serve --mode testdev",
+    "serve-testprod": "vue-cli-service serve --mode testprod",
+    "serve-prod": "vue-cli-service serve --mode prod",
     "serve-eurdev": "vue-cli-service serve --mode eurdev",
     "serve-eurtestdev": "vue-cli-service serve --mode eurtestdev",
     "build": "vue-cli-service build",
-    "build-prod": "vue-cli-service build --mode prod",
     "build-testprod": "vue-cli-service build --mode testprod",
+    "build-prod": "vue-cli-service build --mode prod",
     "build-eurtestprod": "vue-cli-service build --mode eurtestprod",
     "build-eurprod": "vue-cli-service build --mode eurprod",
     "lint": "vue-cli-service lint"

+ 20 - 4
packages/qjkankan-editor/src/api/index.js

@@ -472,7 +472,7 @@ export function setListSort(data, ok, no) {
  */
  export function getMaterialList(data, ok, no) {
     
-    let url = `${URL_FILL}/manage/fodder/list`
+    let url = `${URL_FILL}/manage/fodder/listAndDir`
     // if (data.urlSelect) {
     //     url = `${URL_FILL}/manage/fodder/select/${data.type}/${number()}`
     // }
@@ -480,7 +480,10 @@ export function setListSort(data, ok, no) {
     return http.postJson(url, data, (result)=>{
         $waiting.hide()
         return ok(result)
-    }, no)
+    }, (err) => {
+      $waiting.hide()
+      return no(err)
+    })
 }
 
 /**
@@ -489,8 +492,11 @@ export function setListSort(data, ok, no) {
  * @param {*} ok 
  * @param {*} no 
  */
- export function uploadMaterial(data, subdata, ok, no, onProgress) {
-    return http.uploadFile(`${URL_FILL}/manage/fodder/upload/${subdata.type}/${subdata.uid}`, data, ok, no, onProgress)
+// export function uploadMaterial(data, subdata, ok, no, onProgress) {
+//   return http.uploadFile(`${URL_FILL}/manage/fodder/upload/${subdata.type}/${subdata.uid}`, data, ok, no, onProgress)
+// }
+export function uploadMaterial(data, ok, no, onProgress) {
+  return http.uploadFile(`${URL_FILL}/manage/fodder/uploadDir`, data, ok, no, onProgress)
 }
 
 /**
@@ -513,6 +519,16 @@ export function setListSort(data, ok, no) {
     return http.postJson(`${URL_FILL}/manage/fodder/update`, data, ok, no)
 }
 
+// 获取目录结构
+export function getFolderTree(data, ok, no) {
+  return http.getJson(`${URL_FILL}/manage/dir/getTree/${data.type}`, {}, ok, no)
+}
+
+// 素材库中新增文件夹
+export function createFolder(data, ok, no) {
+  return http.postJson(`${URL_FILL}/manage/dir/save`, data, ok, no)
+}
+
 
 /**
  * 添加我的作品

BIN
packages/qjkankan-editor/src/assets/images/icons/folder-blue.png


+ 38 - 13
packages/qjkankan-editor/src/components/crumbs/index.vue

@@ -1,9 +1,15 @@
 <template>
-  <div class="list">
+  <div class="crumbs">
     <ul>
-      <li v-for="(item,i) in list" :key="i">
-        <span class="name">{{item.name}}</span>
-        <span class="hang">/</span>
+      <li v-if="list[0]">
+        <span class="name" :title="list[0].name" @click="onClickPath(0)">{{list[0].name}}</span>
+      </li>
+      <li v-if="list.length > 3">...</li>
+      <li v-if="list.length > 2">
+        <span class="name" :title="list[list.length - 2].name" @click="onClickPath(list.length - 2)">{{list[list.length - 2].name}}</span>
+      </li>
+      <li v-if="list.length > 1">
+        <span class="name" :title="list[list.length - 1].name" @click="onClickPath(list.length - 1)">{{list[list.length - 1].name}}</span>
       </li>
     </ul>
   </div>
@@ -11,29 +17,48 @@
 
 <script>
 export default {
-  props:['list']
-  
+  props:['list'],
+  methods: {
+    onClickPath(idx) {
+      if (idx !== this.list.length - 1) {
+        this.$emit('click-path', idx)
+      }
+    }
+  }
 }
 </script>
 
 <style lang="less" scoped>
-.list{
+.crumbs{
   >ul{
     display: flex;
+    align-items: center;
     >li{
-      color: #909090;
+      font-size: 18px;
+      color: #969799;
+      line-height: 28px;
       .name{
+        max-width: 285px;
+        overflow: hidden;
+        white-space: pre;
+        text-overflow: ellipsis;
         cursor: pointer;
       }
-      .hang{
-        margin: 0 6px;
+      &::after {
+        content: '>';
+        margin-left: 7px;
+        margin-right: 7px;
       }
       &:last-of-type{
         font-size: 18px;
-        color: #333333;
         font-weight: bold;
-        .hang{
-          display: none;
+        color: #333333;
+        line-height: 28px;
+        .name {
+          cursor: default;
+        }
+        &::after {
+          content: '';
         }
       }
     }

+ 77 - 0
packages/qjkankan-editor/src/components/nestedFolder.vue

@@ -0,0 +1,77 @@
+<template>
+  <div
+    class="scene-group"
+  >
+    <div
+      class="top-bar"
+      @click="onClickTopBar"
+      :style="{
+        paddingLeft: topBarPaddingLeft,
+      }"
+    >
+      <i class="iconfont icon-edit_input_arrow icon-expand" :class="isExpanded ? '' : 'collapsed'"></i>
+      <i v-show="isExpanded" class="iconfont icon-editor_folder_on folder_expanded"></i>
+      <i v-show="!isExpanded" class="iconfont icon-editor_folder_off folder_collapsed"></i>
+      <span class="group-name" v-title="$i18n.t(`zh_key.${groupNode.name}`).indexOf('zh_key')>-1?groupNode.name:$i18n.t(`zh_key.${groupNode.name}`)">{{
+        $i18n.t(`zh_key.${groupNode.name}`).indexOf('zh_key')>-1?groupNode.name:$i18n.t(`zh_key.${groupNode.name}`)}}
+      </span>
+    </div>
+
+    <div class="group-content" v-if="isExpanded">
+        <div
+          v-for="(item, index) of groupNode.children"
+          :key=item.id
+        >
+          <component
+            :is="'SceneGroup'"
+            :groupNode="item"
+            :level="level + 1"
+          />
+        </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapGetters } from "vuex";
+
+export default {
+  name: 'SceneGroup',
+  components: {
+  },
+  props: {
+    groupNode: {
+      type: Object,
+      required: true,
+    },
+    level: {
+      type: Number,
+      default: 1,
+    }
+  },
+  data() {
+    return {
+      isExpanded: false,
+    }
+  },
+  computed: {
+    topBarPaddingLeft() {
+      return 12 + (this.level - 1) * 12 + 'px' 
+    },
+    sceneItemPaddingLeft() {
+      return 18 + this.level * 12 + 'px' 
+    },
+  },
+  methods: {
+    onClickTopBar() {
+      this.isExpanded = !this.isExpanded
+    },
+  },
+  mounted() {
+  },
+  destroyed() {
+  }
+}
+</script>
+
+<style lang="less" scoped></style>

+ 7 - 2
packages/qjkankan-editor/src/lang/_zh.json

@@ -484,7 +484,7 @@
     "upload_done": "上传成功",
     "upload_fail": "上传失败",
     "exception": "异常错误",
-    "network_error": "网络连接失败,请稍后再试",
+    "network_error": "网络异常,请稍后再试",
     "file_notfound": "文件不存在",
     "scene_notfound": "场景不存在",
     "params_notfound": "缺少必要参数",
@@ -567,7 +567,9 @@
       "drag_to_cut":"拖动画面截取封面",
       "cutting":"截图",
       "preview_cover":"封面预览",
-      "rename_material":"重命名素材"
+      "rename_material":"重命名素材",
+      "new_folder": "新建文件夹",
+      "new_folder_placeholder": "请输入文件夹名,限15字"
     }
   },
   "gather": {
@@ -578,6 +580,7 @@
     "my_works": "我的作品",
     "my_material": "我的素材",
     "panorama": "全景图",
+    "pano": "全景图",
     "image": "图片",
     "audio": "音频",
     "video": "视频",
@@ -597,6 +600,8 @@
     "audio_limit": "过大,请上传20MB以内、mp3格式的音频",
     "audio_fail": "格式错误,请上传20MB以内、mp3格式的音频",
     "upload_material": "上传素材",
+    "new_folder": "新建文件夹",
+    "move_folder": "移动",
     "video_size": "请上传200MB以内、mp4格式的视频",
     "video_limit": "过大,请上传200MB以内、mp4格式的视频",
     "video_fail": "格式错误,请上传200MB以内、mp4格式的视频",

+ 60 - 12
packages/qjkankan-editor/src/utils/other.js

@@ -25,36 +25,84 @@
 }
 
 /**
- * 返回一个自带消抖效果的函数,用res表示。
- * 
+ * 返回一个自带消抖效果的函数,下文用fnDebounced表示。
+ *
  * fn: 需要被消抖的函数
  * delay: 消抖时长
- * isImmediateCall: 是在第一次调用时立即执行fn,还是在最后一次调用后等delay时长再调用fn
+ * isImmediateCall: 是否在一组操作中的第一次调用时立即执行fn
+ * isRememberLastCall:是否在一组中最后一次调用后等delay时长再执行fn
+ * 
+ * 如果isRememberLastCall为false,意味着fn不会被延迟执行,所以fnDebounced执行时,要么在内部调用fn,同步返回fn返回值;要么内部决定本次不调用fn,同步返回null。
+ * 如果isRememberLastCall为true,意味着fn可能被延迟执行,所以fnDebounced会返回一个Promise,在fn被调用时用其返回值resolve该Promise,或者在fn的延时调用计划被取消时用'canceled'resolve该Promise。(不宜reject,否则又没有人去catch,会导致浏览器报错。)
  */
-export function debounce(fn, delay, isImmediateCall = false) {
+ export function debounce(fn, delay = 250, isImmediateCall = false, isRememberLastCall = true) {
+  console.assert(isImmediateCall || isRememberLastCall, 'isImmediateCall 和 isRememberLastCall 至少应有一个是true,否则没有意义!')
   let timer = null
+  let retPromiseLastTimeResolver = null
   // 上次调用的时刻
   let lastCallTime = 0
 
-  if (isImmediateCall) {
+  if (isImmediateCall && !isRememberLastCall) {
     return function (...args) {
-      const context = this
+      let ret = null
       const currentTime = Date.now()
       if (currentTime - lastCallTime >= delay) {
-        fn.apply(context, args)
+        ret = fn.apply(this, args)
       }
       lastCallTime = currentTime
+      return ret
     }
-  } else {
+  } else if (!isImmediateCall && isRememberLastCall) {
     return function (...args) {
       if (timer) {
         clearTimeout(timer)
+        timer = null
       }
-      const context = this
-      timer = setTimeout(() => {
-        fn.apply(context, args)
-      }, delay)
+      if (retPromiseLastTimeResolver) {
+        retPromiseLastTimeResolver('canceled')
+        retPromiseLastTimeResolver = null
+      }
+      const ret = new Promise((resolve, reject) => {
+        retPromiseLastTimeResolver = resolve
+        timer = setTimeout(() => {
+          timer = null
+          retPromiseLastTimeResolver = null
+          resolve(fn.apply(this, args))
+        }, delay)
+      })
+      return ret
     }
+  } else if (isImmediateCall && isRememberLastCall) {
+    return function (...args) {
+      const currentTime = Date.now()
+      if (currentTime - lastCallTime >= delay) { // 一组操作中的第一次
+        const res = fn.apply(this, args) 
+        lastCallTime = currentTime
+        return Promise.resolve(res)
+      } else { // 一组中的后续调用
+        if (timer) { // 在此之前存在中间调用
+          lastCallTime = currentTime
+          clearTimeout(timer)
+          timer = null
+        }
+        if (retPromiseLastTimeResolver) {
+          retPromiseLastTimeResolver('canceled')
+          retPromiseLastTimeResolver = null
+        }
+        const ret = new Promise((resolve, reject) => {
+          retPromiseLastTimeResolver = resolve
+          timer = setTimeout(() => {
+            lastCallTime = 0
+            timer = null
+            retPromiseLastTimeResolver = null
+            resolve(fn.apply(this, args))
+          }, delay)
+        })
+        return ret
+      }
+    }
+  } else {
+    console.error('不应该执行到这里!')
   }
 }
 

+ 55 - 18
packages/qjkankan-editor/src/views/material/audio/index.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="panorama con">
     <div class="top">
-      <crumbs :list="tablist" />
+      <crumbs :list="folderPath" @click-path="onClickPath" />
     </div>
     <div class="second-line">
       <div class="btn">
@@ -27,6 +27,13 @@
           ></upload>
         </button>
       </div>
+      <button
+        class="ui-button submit"
+        @click="isShowNewFolder = true"
+      >
+        {{$i18n.t(`gather.new_folder`)}}
+      </button>
+      <button class="ui-button cancel">{{$i18n.t(`gather.move_folder`)}}</button>
       <div class="filter">
         <div :class="{active: isFilterFocus}" @focusin="onFilterFocus" @focusout="onFilterBlur">
           <i class="iconfont icon-works_search search" ></i>
@@ -74,7 +81,10 @@
               </div>
             </i>
           </div>
-          <div class="audio" v-else-if="sub.type == 'audio'">
+          <div
+            v-else-if="sub.type == 'audio' && item.type !== 'dir'"
+            class="audio"
+          >
             <v-audio
               :vkey="item.id"
               :idleft="`_${$randomWord(true, 8, 8)}`"
@@ -82,10 +92,29 @@
               :myAudioUrl="data"
             ></v-audio>
           </div>
-          <span v-else>{{ data || "-" }}</span>
+          <div
+            v-else-if="sub.type == 'audio' && item.type === 'dir'"
+            class="img dir"
+          >
+            <img
+              :src="require('@/assets/images/icons/folder-blue.png')"
+              alt=""
+              @click="onClickFolder(item)"
+            />
+          </div>
+          <span
+            v-else
+            class="textItem"
+            :class="{
+              dirName: sub.key === 'name' && item.type === 'dir'
+            }"
+            @click="(sub.key === 'name' && item.type === 'dir') ? onClickFolder(item): null"
+          >
+            {{ data || "-" }}
+          </span>
         </div>
       </tableList>
-      <UploadTaskList class="upload-task-list" fileType="AUDIO" :taskList="uploadListForUI" @cancel-task="onCancelTask"></UploadTaskList>
+      <UploadTaskList class="upload-task-list" fileType="AUDIO" :taskList="uploadListForUI" :targetFolderId="currentFolderId" @cancel-task="onCancelTask"></UploadTaskList>
       <div class="total-number" v-if="list.length !== 0 || hasMoreData">{{had_load}}</div>
       <div class="nodata" v-if="list.length == 0 && !hasMoreData && lastestUsedSearchKey">
         <img :src="$noresult" alt="" />
@@ -98,6 +127,12 @@
       </div>
     </div>
 
+    <CreateFolder
+      v-if="isShowNewFolder"
+      :validate=validateNewFolderName
+      @close="isShowNewFolder = false"
+      @submit="onSubmitNewFolder"
+    />
     <rename
       v-if="showRename"
       :item="popupItem"
@@ -120,6 +155,7 @@ import UploadTaskList from "../components/uploadList1.1.0.vue";
 import { debounce } from "@/utils/other.js"
 import { mapState } from 'vuex';
 import {i18n} from "@/lang"
+import folderMixinFactory from "../folderMixinFactory.js";
 
 import {
   getMaterialList,
@@ -131,7 +167,12 @@ import {
 
 const TYPE = "audio";
 
+const folderMixin = folderMixinFactory(TYPE)
+
 export default {
+  mixins: [
+    folderMixin,
+  ],
   components: {
     tableList,
     crumbs,
@@ -160,17 +201,10 @@ export default {
       tabHeader: data,
       selectedArr: [],
       
-      searchKey: "",
       // 因为searchKey的变化经过debounce、异步请求的延时,才会反映到数据列表的变化上,所以是否显示、显示哪种无数据提示,也要等到数据列表变化后,根据数据列表是否为空,以及引发本次变化的那个searchKey瞬时值来决定。本变量就是用来保存那个瞬时值。
       lastestUsedSearchKey: '',
       isFilterFocus: false,
-      
-      tablist: [
-        {
-          name: i18n.t("gather.audio"),
-          id: TYPE,
-        },
-      ],
+      searchKey: "",
       list: [],
       hasMoreData: true,
       isRequestingMoreData: false,
@@ -216,7 +250,7 @@ export default {
       this.isRequestingMoreData = false
       this.hasMoreData = true
       this.$refs['table-list'].requestMoreData()
-    }, 700, false),
+    }, 700, true),
     stopAllAudio() {
       Array.from($("audio")).forEach((item) => {
         if (!item.paused) {
@@ -325,15 +359,15 @@ export default {
           statusText: i18n.t("gather.uploading_material"),
           uid: `u_${this.$randomWord(true, 8, 8)}`,
           abortHandler: null,
+          parentFolderId: this.currentFolderId,
         };
         
         itemInUploadList.abortHandler = uploadMaterial(
           {
-            file: eachFile
-          },
-          {
+            dirId: this.currentFolderId,
+            file: eachFile,
+            temId: itemInUploadList.uid,
             type: TYPE,
-            uid: itemInUploadList.uid,
           },
           () => { // 上传成功
             const index = this.uploadListForUI.findIndex((eachItem) => {
@@ -376,6 +410,7 @@ export default {
       const lastestUsedSearchKey = this.searchKey
       getMaterialList(
         {
+          dirId: this.currentFolderId,
           pageNum: Math.floor(this.list.length / config.PAGE_SIZE) + 1,
           pageSize: config.PAGE_SIZE,
           searchKey: this.searchKey,
@@ -383,7 +418,9 @@ export default {
         },
         (data) => {
           const newData = data.data.list.map((i) => {
-            i.fileSize = changeByteUnit(Number(i.fileSize));
+            if (i.type !== 'dir') {
+              i.fileSize = changeByteUnit(Number(i.fileSize));
+            }
             i.createTime = i.createTime.substring(0, i.createTime.length - 3)
             i.updateTime = i.updateTime.substring(0, i.updateTime.length - 3)
             return i;

+ 12 - 3
packages/qjkankan-editor/src/views/material/components/uploadList1.1.0.vue

@@ -1,6 +1,6 @@
 <template>
   <!-- 这是一个简单组件,只关注视图层 -->
-  <div v-show="taskList.length > 0" class="upload-list-component">
+  <div v-show="taskListForShow.length > 0" class="upload-list-component">
     <div class="head">
       <div class="left">
         <i class="iconfont iconmaterial_preview_upload"></i>
@@ -16,7 +16,7 @@
     <div class="content" v-show="expandSwitch">
       <div
         class="list-item" 
-        v-for="(taskItem) in taskList"
+        v-for="(taskItem) in taskListForShow"
         :key="taskItem.uid"
       >
         <div class="left">
@@ -100,6 +100,10 @@ export default {
       //   },
       // ]
     },
+    targetFolderId: {
+      type: Number,
+      required: true,
+    }
   },
   computed: {
     uploadFieIconUrl() {
@@ -115,7 +119,12 @@ export default {
       }
     },
     uploading(){
-      return i18n.t("gather.uploading",{msg:this.taskList.length})
+      return i18n.t("gather.uploading",{msg:this.taskListForShow.length})
+    },
+    taskListForShow() {
+      return this.taskList.filter((item) => {
+        return item.parentFolderId === this.targetFolderId
+      })
     }
   },
   data() {

+ 109 - 0
packages/qjkankan-editor/src/views/material/folderMixinFactory.js

@@ -0,0 +1,109 @@
+import CreateFolder from "./popup/CreateFolder";
+import {
+  getMaterialList,
+  getFolderTree,
+  createFolder as createFolderApi,
+} from "@/api";
+import {i18n} from "@/lang"
+
+export default function(materialType) {
+  return {
+    components: {
+      CreateFolder,
+    },
+    data() {
+      return {
+        isShowNewFolder: false,
+        folderTree: null,
+        folderPath: [
+          {
+            name: i18n.t(`gather.${materialType}`),
+            id: 1,
+          },
+        ],
+        folderListInPath: [],
+      }
+    },
+    computed: {
+      currentFolderId() {
+        return this.folderPath[this.folderPath.length - 1].id
+      }
+    },
+    mounted() {
+      getFolderTree({
+        type: materialType,
+      }).then((res) => {
+        this.folderTree = res.data
+      })
+      this.getAllFolderInPath()
+    },
+    watch: {
+      folderPath: {
+        handler: function () {
+          this.refreshListDebounced()
+          this.getAllFolderInPath()
+        },
+        deep: true,
+      }
+    },
+    methods: {
+      getAllFolderInPath() {
+        getMaterialList(
+          {
+            dirId: this.currentFolderId,
+            type: materialType,
+          },
+          (res) => {
+            this.folderListInPath = res.data.list.filter((item) => {
+              return item.type === 'dir'
+            })
+          },
+          () => {
+          }
+        )
+      },
+      validateNewFolderName(name) {
+        const isUnique = (this.folderListInPath.findIndex((item) => {
+          return item.name === name
+        }) === -1)
+        if (isUnique) {
+          return {
+            isValid: true,
+            tip: '',
+          }
+        } else {
+          return {
+            isValid: false,
+            tip: '文件夹已存在'
+          }
+        }
+      },
+      onSubmitNewFolder(v) {
+        this.isShowNewFolder = false
+        createFolderApi(
+          {
+            name: v,
+            parentId: this.currentFolderId,
+            type: materialType,
+          },
+          () => {
+            this.$msg.success(this.$i18n.t('gather.success'))
+            this.refreshListDebounced()
+          },
+          () => {
+            this.$msg.error(this.$i18n.t('tips.network_error'))
+          }
+        )
+      },
+      onClickPath(idx) {
+        this.folderPath = this.folderPath.slice(0, idx + 1)
+      },
+      onClickFolder(folder) {
+        this.folderPath.push({
+          name: folder.name,
+          id: folder.id,
+        })
+      },
+    }
+  }
+}

+ 47 - 21
packages/qjkankan-editor/src/views/material/image/index.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="panorama con">
     <div class="top">
-      <crumbs :list="tablist" />
+      <crumbs :list="folderPath" @click-path="onClickPath" />
     </div>
     <div class="second-line">
       <div class="btn">
@@ -27,6 +27,13 @@
           ></upload>
         </button>
       </div>
+      <button
+        class="ui-button submit"
+        @click="isShowNewFolder = true"
+      >
+        {{$i18n.t(`gather.new_folder`)}}
+      </button>
+      <button class="ui-button cancel">{{$i18n.t(`gather.move_folder`)}}</button>
       <div class="filter">
         <div :class="{active: isFilterFocus}" @focusin="onFilterFocus" @focusout="onFilterBlur">
           <i class="iconfont icon-works_search search" ></i>
@@ -74,21 +81,33 @@
               </div>
             </i>
           </div>
-          <div v-else-if="sub.type == 'image'" class="img">
+          <div
+            v-else-if="sub.type == 'image'"
+            class="img"
+            :class="{
+              dirIcon: item.type === 'dir'
+            }"
+          >
             <img
               :id="'img' + item.id"
-              :src="data + (Number(item.fileSize)>512 ? $imgsuffix : '') "
+              :src="item.type === 'dir' ? require('@/assets/images/icons/folder-blue.png') : data + (Number(item.fileSize)>512 ? $imgsuffix : '') "
               alt=""
-              @click="previewImage(item)"
+              @click="item.type === 'dir' ? onClickFolder(item) : previewImage(item)"
             />
           </div>
           <span
             v-else
-            >{{ data || "-" }}</span
+            class="textItem"
+            :class="{
+              dirName: sub.key === 'name' && item.type === 'dir'
+            }"
+            @click="(sub.key === 'name' && item.type === 'dir') ? onClickFolder(item): null"
           >
+            {{ data || "-" }}
+          </span>
         </div>
       </tableList>
-      <UploadTaskList class="upload-task-list" fileType="IMAGE" :taskList="uploadListForUI" @cancel-task="onCancelTask"></UploadTaskList>
+      <UploadTaskList class="upload-task-list" fileType="IMAGE" :taskList="uploadListForUI" :targetFolderId="currentFolderId" @cancel-task="onCancelTask"></UploadTaskList>
       <div class="total-number" v-if="list.length !== 0 || hasMoreData">{{had_load}}</div>
       <div class="nodata" v-if="list.length == 0 && !hasMoreData && lastestUsedSearchKey">
         <img :src="$noresult" alt="" />
@@ -101,6 +120,12 @@
       </div>
     </div>
 
+    <CreateFolder
+      v-if="isShowNewFolder"
+      :validate=validateNewFolderName
+      @close="isShowNewFolder = false"
+      @submit="onSubmitNewFolder"
+    />
     <rename
       v-if="showRename"
       :item="popupItem"
@@ -129,6 +154,8 @@ import { changeByteUnit } from "@/utils/file";
 import preview from "../popup/imagePreviewer.vue";
 import { debounce } from "@/utils/other.js"
 import { mapState } from 'vuex';
+import {i18n} from "@/lang"
+import folderMixinFactory from "../folderMixinFactory.js";
 
 import {
   getMaterialList,
@@ -138,12 +165,15 @@ import {
   checkUserSize
 } from "@/api";
 
-import {i18n} from "@/lang"
-
 
 const TYPE = "image";
 
+const folderMixin = folderMixinFactory(TYPE)
+
 export default {
+  mixins: [
+    folderMixin,
+  ],
   components: {
     tableList,
     crumbs,
@@ -173,14 +203,7 @@ export default {
       // 因为searchKey的变化经过debounce、异步请求的延时,才会反映到数据列表的变化上,所以是否显示、显示哪种无数据提示,也要等到数据列表变化后,根据数据列表是否为空,以及引发本次变化的那个searchKey瞬时值来决定。本变量就是用来保存那个瞬时值。
       lastestUsedSearchKey: '',
       isFilterFocus: false,
-
       searchKey: "",
-      tablist: [
-        {
-          name: i18n.t("gather.image"),
-          id: TYPE,
-        },
-      ],
       list: [],
       hasMoreData: true,
       isRequestingMoreData: false,
@@ -226,7 +249,7 @@ export default {
       this.isRequestingMoreData = false
       this.hasMoreData = true
       this.$refs['table-list'].requestMoreData()
-    }, 700, false),
+    }, 700, true),
     handleRename(newName) {
       editMaterial(
         {
@@ -339,15 +362,15 @@ export default {
           statusText: i18n.t("gather.uploading_material"),
           uid: `u_${this.$randomWord(true, 8, 8)}`,
           abortHandler: null,
+          parentFolderId: this.currentFolderId,
         };
         
         itemInUploadList.abortHandler = uploadMaterial(
           {
-            file: eachFile
-          },
-          {
+            dirId: this.currentFolderId,
+            file: eachFile,
+            temId: itemInUploadList.uid,
             type: TYPE,
-            uid: itemInUploadList.uid,
           },
           () => { // 上传成功
             const index = this.uploadListForUI.findIndex((eachItem) => {
@@ -390,6 +413,7 @@ export default {
       const lastestUsedSearchKey = this.searchKey
       getMaterialList(
         {
+          dirId: this.currentFolderId,
           pageNum: Math.floor(this.list.length / config.PAGE_SIZE) + 1,
           pageSize: config.PAGE_SIZE,
           searchKey: this.searchKey,
@@ -397,7 +421,9 @@ export default {
         },
         (data) => {
           const newData = data.data.list.map((i) => {
-            i.fileSize = changeByteUnit(Number(i.fileSize));
+            if (i.type !== 'dir') {
+              i.fileSize = changeByteUnit(Number(i.fileSize));
+            }
             i.createTime = i.createTime.substring(0, i.createTime.length - 3)
             i.updateTime = i.updateTime.substring(0, i.updateTime.length - 3)
             return i;

+ 83 - 27
packages/qjkankan-editor/src/views/material/pano/index.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="panorama con">
     <div class="top">
-      <crumbs :list="tablist" />
+      <crumbs :list="folderPath" @click-path="onClickPath" />
     </div>
     <div class="second-line">
       <div class="btn">
@@ -16,6 +16,13 @@
             media-type="image" :limit="120" @file-change="onFileChange"></upload>
         </button>
       </div>
+      <button
+        class="ui-button submit"
+        @click="isShowNewFolder = true"
+      >
+        {{$i18n.t(`gather.new_folder`)}}
+      </button>
+      <button class="ui-button cancel">{{$i18n.t(`gather.move_folder`)}}</button>
       <div class="filter">
         <div :class="{ active: isFilterFocus }" @focusin="onFilterFocus" @focusout="onFilterBlur">
           <i class="iconfont icon-works_search search"></i>
@@ -60,19 +67,49 @@
             </i>
           </div>
           <!-- 图片型单元格 -->
-          <div class="img" v-else-if="sub.type == 'image'" @click="previewImage(item)">
+          <div
+            v-else-if="sub.type == 'image' && item.type !== 'dir'"
+            class="img"
+            @click="previewImage(item)"
+          >
             <img :src="data + (Number(item.fileSize) > 512 ? $imgsuffix : '')"
               alt="" />
           </div>
-          <span style="cursor: pointer;" @click="previewImage(item)" v-else-if="sub.key == 'name'">{{ data || "-" }}
+          <div
+            v-else-if="sub.type == 'image' && item.type === 'dir'"
+            class="img dir"
+          >
+            <img
+              :src="require('@/assets/images/icons/folder-blue.png')"
+              alt=""
+              @click="onClickFolder(item)"
+            />
+          </div>
+          <span
+            v-else-if="sub.key == 'name' && item.type !== 'dir'"
+            class="textItem"
+            style="cursor: pointer;"
+            @click="previewImage(item)"
+          >
+            {{ data || "-" }}
+          </span>
+          <span
+            v-else-if="sub.key === 'name' && item.type === 'dir'"
+            class="textItem dirName"
+            @click="onClickFolder(item)"
+          >
+            {{ data || "-" }}
           </span>
-
           <!-- 文字型单元格 -->
-          <span v-else>{{ data || "-" }}
+          <span
+            v-else
+            class="textItem"
+          >
+            {{ data || "-" }}
           </span>
         </div>
       </tableList>
-      <UploadTaskList class="upload-task-list" fileType="IMAGE" :taskList="uploadListForUI" @cancel-task="onCancelTask">
+      <UploadTaskList class="upload-task-list" fileType="IMAGE" :taskList="uploadListForUI" :targetFolderId="currentFolderId" @cancel-task="onCancelTask">
       </UploadTaskList>
       <div class="total-number" v-if="list.length !== 0 || hasMoreData">{{ had_load }}</div>
       <div class="nodata" v-if="list.length == 0 && !hasMoreData && lastestUsedSearchKey">
@@ -86,10 +123,30 @@
       </div>
     </div>
 
-    <rename v-if="showRename" :item="popupItem" @rename="handleRename" @close="showRename = false" />
-    <preview ref="image-previewer" :sceneCodeList="list.map(item => item.sceneCode)"
-      :imageTitleList="list.map(item => item.name)" @click-delete="onClickDeleteInPreview" />
-    <cover @panocover="handlePanoCover" :item="popupItem" v-if="showCover" @close="showCover = false" />
+    <CreateFolder
+      v-if="isShowNewFolder"
+      :validate=validateNewFolderName
+      @close="isShowNewFolder = false"
+      @submit="onSubmitNewFolder"
+    />
+    <rename
+      v-if="showRename" 
+      :item="popupItem"
+      @rename="handleRename"
+      @close="showRename = false" 
+    />
+    <preview
+      ref="image-previewer"
+      :sceneCodeList="list.map(item => item.sceneCode)"
+      :imageTitleList="list.map(item => item.name)"
+      @click-delete="onClickDeleteInPreview"
+    />
+    <cover
+      @panocover="handlePanoCover"
+      :item="popupItem"
+      v-if="showCover"
+      @close="showCover = false"
+    />
   </div>
 </template>
 
@@ -106,6 +163,8 @@ import { getImgWH, changeByteUnit } from "@/utils/file";
 import UploadTaskList from "../components/uploadList1.1.0.vue";
 import { debounce } from "@/utils/other.js"
 import { mapState } from 'vuex';
+import { i18n } from "@/lang"
+import folderMixinFactory from "../folderMixinFactory.js";
 
 import {
   getMaterialList,
@@ -117,13 +176,15 @@ import {
   checkUserSize
 } from "@/api";
 
-import { i18n } from "@/lang"
-
 
 const TYPE = "pano";
 const LONG_POLLING_INTERVAL = 5;
+const folderMixin = folderMixinFactory(TYPE)
 
 export default {
+  mixins: [
+    folderMixin,
+  ],
   name: 'Pano',
   components: {
     tableList,
@@ -155,21 +216,13 @@ export default {
       tabHeader: data,
       selectedArr: [],
 
-      searchKey: "",
       // 因为searchKey的变化经过debounce、异步请求的延时,才会反映到数据列表的变化上,所以是否显示、显示哪种无数据提示,也要等到数据列表变化后,根据数据列表是否为空,以及引发本次变化的那个searchKey瞬时值来决定。本变量就是用来保存那个瞬时值。
       lastestUsedSearchKey: '',
       isFilterFocus: false,
-
-      tablist: [
-        {
-          name: i18n.t("gather.panorama"),
-          id: TYPE,
-        },
-      ],
+      searchKey: "",
       list: [],
       hasMoreData: true,
       isRequestingMoreData: false,
-      uploadList: [],
     };
   },
   computed: {
@@ -233,7 +286,7 @@ export default {
       this.isRequestingMoreData = false
       this.hasMoreData = true
       this.$refs['table-list'].requestMoreData()
-    }, 700, false),
+    }, 700, true),
     clearinter() {
       this.interval && clearInterval(this.interval);
       this.interval = null;
@@ -457,15 +510,15 @@ export default {
           uid: `u_${this.$randomWord(true, 8, 8)}`,
           abortHandler: null,
           backendId: '',
+          parentFolderId: this.currentFolderId,
         };
 
         itemInUploadList.abortHandler = uploadMaterial(
           {
-            file: eachFile
-          },
-          {
+            dirId: this.currentFolderId,
+            file: eachFile,
+            tempId: itemInUploadList.uid,
             type: TYPE,
-            uid: itemInUploadList.uid,
           },
           (response) => { // 上传成功
             itemInUploadList.statusText = i18n.t("gather.cutting")
@@ -506,6 +559,7 @@ export default {
       const lastestUsedSearchKey = this.searchKey
       getMaterialList(
         {
+          dirId: this.currentFolderId,
           pageNum: Math.floor(this.list.length / config.PAGE_SIZE) + 1,
           pageSize: config.PAGE_SIZE,
           searchKey: this.searchKey,
@@ -514,7 +568,9 @@ export default {
         },
         (data) => {
           const newData = data.data.list.map((i) => {
-            i.fileSize = changeByteUnit(Number(i.fileSize));
+            if (i.type !== 'dir') {
+              i.fileSize = changeByteUnit(Number(i.fileSize));
+            }
             i.createTime = i.createTime.substring(0, i.createTime.length - 3)
             i.updateTime = i.updateTime.substring(0, i.updateTime.length - 3)
             return i;

+ 155 - 0
packages/qjkankan-editor/src/views/material/popup/CreateFolder.vue

@@ -0,0 +1,155 @@
+<template>
+  <popup>
+    <div class="ui-message ui-message-confirm" style="width: 400px">
+      <div class="ui-message-header">
+        <span>{{$i18n.t(`material.components.new_folder`)}}</span>
+        <span @click="$emit('close')">
+          <i class="iconfont icon_close"></i>
+        </span>
+      </div>
+      <div class="input-wrap">
+        <input
+          class="ui-input"
+          :class="{
+            invalid: !validateRes.isValid,
+          }"
+          type="text"
+          maxlength="15"
+          :placeholder="$i18n.t(`material.components.new_folder_placeholder`)"
+          @input="emojistr" v-model="key"
+        />
+        <div v-if="!validateRes.isValid" class="invalid-tip">
+          {{validateRes.tip}}
+        </div>
+      </div>
+      <div class="ui-message-footer">
+        <div class="btn">
+          <button @click="$emit('close')" class="ui-button ui-button-rect cancel">
+            {{$i18n.t(`gather.cancel`)}}
+          </button>
+          <button
+            @click="onClickConfirm"
+            class="ui-button ui-button-rect submit"
+            :class="{disable: !key || !validateRes.isValid}"
+          >
+            {{$i18n.t(`gather.comfirm`)}}
+          </button>
+        </div>
+      </div>
+    </div>
+  </popup>
+</template>
+
+<script>
+import Popup from "@/components/shared/popup";
+
+export default {
+  components: {
+    Popup
+  },
+  props: {
+    validate: {
+      type: Function,
+      default: function() {
+        return {
+          isValid: true,
+          tip: '',
+        }
+      },
+    },
+  },
+  data() {
+    return {
+      key: '',
+      validateRes: {
+        isValid: true,
+        tip: '',
+      }
+    }
+  },
+  watch: {
+    key: {
+      handler(v) {
+        this.validateRes = this.validate(v)
+      }
+    }
+  },
+  methods: {
+    emojistr() {
+      this.key = this.key.replace(/(\ud83c[\udf00-\udfff])|(\ud83d[\udc00-\ude4f])|(\ud83d[\ude80-\udeff])/g, function (char) {
+        if (char.length === 2) {
+          return ""
+        } else {
+          return char;
+        }
+      });
+    },
+    onClickConfirm() {
+      if (!this.key.trim()) {
+        return this.$alert({ content: "请输入名字" });
+      }
+      this.$emit('submit', this.key)
+    },
+  }
+}
+</script>
+
+<style lang="less" scoped>
+
+
+.ui-message-confirm {
+  width: 400px;
+  height: 230px;
+
+  .ui-message-header {
+    .icon_close {
+      color: #969799;
+    }
+  }
+  > .input-wrap {
+    margin: 40px 0;
+    position: relative;
+    > input.ui-input {
+      height: 36px;
+      color: #323233;
+      font-size: 14px;
+      border-radius: 4px;
+      border: 1px solid #EBEDF0;
+      &:focus {
+        border: 1px solid @color;
+      }
+    }
+    > input::placeholder {
+      font-size: 14px;
+      color: #969799 !important;
+    }
+    > input.ui-input.invalid {
+      border: 1px solid red;
+    }
+    > .invalid-tip {
+      position: absolute;
+      top: calc(100% + 0.5em);
+      left: 0.5em;
+      font-size: 14px;
+      color: red;
+    }
+  }
+  .ui-message-footer {
+    width: 100%;
+
+    .btn {
+      display: flex;
+      justify-content: flex-end;
+
+      .ui-button {
+        max-width: 104px
+      }
+    }
+
+  }
+}
+</style>
+
+<style lang="less" scoped>
+@import '../style.less';
+</style>

+ 17 - 2
packages/qjkankan-editor/src/views/material/style.less

@@ -12,9 +12,10 @@
   .second-line {
     margin-top: 18px;
     display: flex;
-    justify-content: space-between;
+    // justify-content: space-between;
     align-items: center;
     .btn{
+      margin-right: 16px;
       .ui-button{
         font-size: 14px;
         position: relative;
@@ -38,6 +39,12 @@
         }
       }
     }
+    > button {
+      margin-right: 16px;
+    }
+    > .filter {
+      margin-left: auto;
+    }
   }
 
   >.list{
@@ -49,17 +56,25 @@
       position: relative;
       overflow: hidden;
       cursor: pointer;
-      >img{
+      > img{
         width: 100%;
         height: 100%;
         object-fit: cover;
         background: #F5F7FA;
       }
     }
+    .img.dirIcon {
+      > img {
+        object-fit: contain;
+      }
+    }
     .audio{
       position: relative;
       text-align: left;
     }
+    .textItem.dirName {
+      cursor: pointer;
+    }
     .total-number {
       margin-top: 14px;
       text-align: right;

+ 49 - 18
packages/qjkankan-editor/src/views/material/video/index.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="panorama con">
     <div class="top">
-      <crumbs :list="tablist" />
+      <crumbs :list="folderPath" @click-path="onClickPath" />
     </div>
     <div class="second-line">
       <div class="btn">
@@ -27,6 +27,13 @@
           ></upload>
         </button>
       </div>
+      <button
+        class="ui-button submit"
+        @click="isShowNewFolder = true"
+      >
+        {{$i18n.t(`gather.new_folder`)}}
+      </button>
+      <button class="ui-button cancel">{{$i18n.t(`gather.move_folder`)}}</button>
       <div class="filter">
           <div :class="{active: isFilterFocus}" @focusin="onFilterFocus" @focusout="onFilterBlur">
             <i class="iconfont icon-works_search search"></i>
@@ -75,8 +82,8 @@
             </i>
           </div>
           <div
+            v-else-if="sub.type == 'image' && item.type !== 'dir'"
             class="img"
-            v-else-if="sub.type == 'image'"
             @click="previewVedio(item)"
           >
             <div class="video-icon-mask">
@@ -84,13 +91,29 @@
             </div>
             <img :src="`${data}` || $thumb" alt="" />
           </div>
+          <div
+            v-else-if="sub.type == 'image' && item.type === 'dir'"
+            class="img dir"
+          >
+            <img
+              :src="require('@/assets/images/icons/folder-blue.png')"
+              alt=""
+              @click="onClickFolder(item)"
+            />
+          </div>
           <span
             v-else
-            >{{ data || "-" }}</span
+            class="textItem"
+            :class="{
+              dirName: sub.key === 'name' && item.type === 'dir'
+            }"
+            @click="(sub.key === 'name' && item.type === 'dir') ? onClickFolder(item): null"
           >
+            {{ data || "-" }}
+          </span>
         </div>
       </tableList>
-      <UploadTaskList class="upload-task-list" fileType="VIDEO" :taskList="uploadListForUI" @cancel-task="onCancelTask"></UploadTaskList>
+      <UploadTaskList class="upload-task-list" fileType="VIDEO" :taskList="uploadListForUI" :targetFolderId="currentFolderId" @cancel-task="onCancelTask"></UploadTaskList>
       <div class="total-number" v-if="list.length !== 0 || hasMoreData">{{had_load}}</div>
       <div class="nodata" v-if="list.length == 0 && !hasMoreData && lastestUsedSearchKey">
         <img :src="$noresult" alt="" />
@@ -103,6 +126,12 @@
       </div>
     </div>
 
+    <CreateFolder
+      v-if="isShowNewFolder"
+      :validate=validateNewFolderName
+      @close="isShowNewFolder = false"
+      @submit="onSubmitNewFolder"
+    />
     <rename
       v-if="showRename"
       :item="popupItem"
@@ -129,6 +158,7 @@ import UploadTaskList from "../components/uploadList1.1.0.vue";
 import { debounce } from "@/utils/other.js"
 import { mapState } from 'vuex';
 import {i18n} from "@/lang"
+import folderMixinFactory from "../folderMixinFactory.js";
 
 import {
   getMaterialList,
@@ -140,7 +170,12 @@ import {
 
 const TYPE = "video";
 
+const folderMixin = folderMixinFactory(TYPE)
+
 export default {
+  mixins: [
+    folderMixin,
+  ],
   components: {
     tableList,
     preview,
@@ -169,17 +204,10 @@ export default {
       tabHeader: data,
       selectedArr: [],
 
-      searchKey: "",
       // 因为searchKey的变化经过debounce、异步请求的延时,才会反映到数据列表的变化上,所以是否显示、显示哪种无数据提示,也要等到数据列表变化后,根据数据列表是否为空,以及引发本次变化的那个searchKey瞬时值来决定。本变量就是用来保存那个瞬时值。
       lastestUsedSearchKey: '',
       isFilterFocus: false,
-
-      tablist: [
-        {
-          name: i18n.t("gather.video"),
-          id: TYPE,
-        },
-      ],
+      searchKey: "",
       list: [],
       hasMoreData: true,
       isRequestingMoreData: false,
@@ -231,7 +259,7 @@ export default {
       this.isRequestingMoreData = false
       this.hasMoreData = true
       this.$refs['table-list'].requestMoreData()
-    }, 700, false),
+    }, 700, true),
     handleRename(newName) {
       editMaterial(
         {
@@ -333,15 +361,15 @@ export default {
           statusText: i18n.t("gather.uploading_material"),
           uid: `u_${this.$randomWord(true, 8, 8)}`,
           abortHandler: null,
+          parentFolderId: this.currentFolderId,
         };
         
         itemInUploadList.abortHandler = uploadMaterial(
           {
-            file: eachFile
-          },
-          {
+            dirId: this.currentFolderId,
+            file: eachFile,
+            temId: itemInUploadList.uid,
             type: TYPE,
-            uid: itemInUploadList.uid,
           },
           () => { // 上传成功
             const index = this.uploadListForUI.findIndex((eachItem) => {
@@ -384,6 +412,7 @@ export default {
       const lastestUsedSearchKey = this.searchKey
       getMaterialList(
         {
+          dirId: this.currentFolderId,
           pageNum: Math.floor(this.list.length / config.PAGE_SIZE) + 1,
           pageSize: config.PAGE_SIZE,
           searchKey: this.searchKey,
@@ -391,7 +420,9 @@ export default {
         },
         (data) => {
           const newData = data.data.list.map((i) => {
-            i.fileSize = changeByteUnit(Number(i.fileSize));
+            if (i.type !== 'dir') {
+              i.fileSize = changeByteUnit(Number(i.fileSize));
+            }
             i.icon = process.env.VUE_APP_ORIGIN=='aws'?i.icon:(i.ossPath + '?x-oss-process=video/snapshot,t_0,f_jpg,w_89,h_50,m_fast,ar_auto');
             i.createTime = i.createTime.substring(0, i.createTime.length - 3)
             i.updateTime = i.updateTime.substring(0, i.updateTime.length - 3)