Quellcode durchsuchen

feat(PromotionTable): 完善预览

fxs vor 8 Monaten
Ursprung
Commit
f6d4e9be16

+ 1 - 0
auto-imports.d.ts

@@ -8,5 +8,6 @@ export {}
 declare global {
   const ElBtnType2: typeof import('element-plus/es')['ElBtnType2']
   const ElMessage: typeof import('element-plus/es')['ElMessage']
+  const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
   const ElNotification: (typeof import('element-plus/es'))['ElNotification']
 }

+ 1 - 0
components.d.ts

@@ -60,6 +60,7 @@ declare module 'vue' {
     StatisticCard: typeof import('./src/components/statisticCard/StatisticCard.vue')['default']
     Table: typeof import('./src/components/table/Table.vue')['default']
     TableQueryForm: typeof import('./src/components/table/TableQueryForm.vue')['default']
+    TagSelect: typeof import('./src/components/Material/TagSelect.vue')['default']
     TimeLineChart: typeof import('./src/components/echarts/TimeLineChart.vue')['default']
   }
   export interface ComponentCustomProperties {

+ 9 - 0
src/assets/css/common.css

@@ -10,10 +10,19 @@
     width: 240px !important;
 }
 
+.w100{
+    width: 100px !important;
+}
+
 .h30{
     height: 30px !important;
 }
 
 .bottomBorder {
     border-bottom: 1px solid #e8eaec;
+}
+
+.tableFieldImg{
+    width: 50px;
+    height: 50px;
 }

BIN
src/assets/icon/file/img-load-fail.png


+ 177 - 0
src/components/Material/TagSelect.vue

@@ -0,0 +1,177 @@
+<script setup lang="ts">
+import { nextTick, onMounted, ref } from 'vue'
+import type {
+  AddTagRes,
+  TagItem,
+} from '@/views/Material/types/uploadFileType.ts'
+import { useMaterial } from '@/hooks/Material/useMaterial.ts'
+import { ElMessageBox } from 'element-plus'
+import axiosInstance from '@/utils/axios/axiosInstance.ts'
+import { MaterialAPI } from '@/config/API.ts'
+
+const tagInput = ref('') // 标签输
+
+const selectedTag = defineModel<number[]>('selectedTag', {
+  default: [],
+}) // 已选标签
+
+const autocompleteRef = ref()
+
+const allTags = ref<TagItem[]>([])
+
+const { getAllTags } = useMaterial()
+const { createFilter } = useMaterial()
+
+/**
+ * 标签搜索
+ * @param queryString 搜索值
+ * @param cb 回调
+ */
+const tagSearch = async (queryString: string, cb: any) => {
+  const result = queryString
+    ? allTags.value.filter(createFilter(queryString))
+    : allTags.value
+
+  // console.log(allTags.value)
+  cb(result)
+}
+
+/**
+ * 处理标签选择后的事件
+ * @param tags 选中的标签
+ */
+const handleTagSelect = (tags: TagItem) => {
+  if (selectedTag.value.includes(tags.id)) {
+    ElMessage.warning('标签已存在')
+  } else {
+    selectedTag.value.push(tags.id)
+    console.log(selectedTag.value)
+  }
+  // 需要在下一帧出发blur,否则无法选中
+  nextTick(() => {
+    autocompleteRef.value.blur()
+    tagInput.value = ''
+  })
+}
+
+/**
+ * 更新tag标签列表
+ */
+const updateTags = async () => {
+  const tags = await getAllTags()
+  allTags.value.splice(0, allTags.value.length, ...tags)
+}
+
+/**
+ * 新增tag
+ *
+ * 这里需要nextick,在下一次渲染时执行,因为defineModel的双向绑定是基于事件去更新外部的值的
+ * 就导致在tagAdd的时候,selectedTag.value的值还没有更新,而又触发了splice,所以会错误的删除上一个标签。
+ * 而手动输入的值,则在await的时候跟外部同步更新了,而没有被正确拦截
+ *
+ * @param val 传新增值
+ */
+const tagAdd = (val: string) => {
+  nextTick(async () => {
+    const isExist = allTags.value.find(item => item.name === val)
+    const newTagName = val
+    // 首先要判断是否已经存在这个标签,不存在则需要先走新建流程,所以不要直接添加进去,先移除
+    selectedTag.value.splice(selectedTag.value.length - 1, 1)
+    let id = -1
+    if (!isExist) {
+      const confirm = await ElMessageBox.confirm('不存在改标签,是否创建?', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning',
+      })
+      if (!confirm) return
+      const res = (await axiosInstance.post(MaterialAPI.addTag, {
+        name: newTagName,
+      })) as AddTagRes
+      if (res.code !== 0) {
+        ElMessage.error('创建标签失败')
+        return
+      }
+      ElMessage.success('创建标签成功')
+
+      await updateTags()
+      id = res.data.id
+    } else {
+      id = isExist.id
+    }
+    // rowSelectedTag.value.push(id)
+    selectedTag.value.push(id)
+  })
+}
+
+/**
+ * 清空标签输入框
+ */
+const clearTagInput = () => {
+  selectedTag.value = []
+}
+
+/**
+ * 处理标签的显示值
+ * @param id 标签id
+ */
+const handleTagVal = (id: number) => {
+  const name = allTags.value.find(item => item.id === id)?.name
+  if (!name || name === '') {
+    return '默认'
+  }
+  return name
+}
+
+onMounted(async () => {
+  await updateTags()
+  // selectedTag.value.splice(0, selectedTag.value.length, ...rowSelectedTag.value)
+})
+</script>
+
+<template>
+  <div class="tagContainer">
+    <!--    &lt;!&ndash;     这里需要修改value-key,否则会导致警告         &ndash;&gt;-->
+    <el-autocomplete
+      :debounce="200"
+      v-model="tagInput"
+      value-key="name"
+      :fetch-suggestions="tagSearch"
+      clearable
+      class="inline-input w220"
+      placeholder="请输入标签以搜索"
+      @select="handleTagSelect"
+      ref="autocompleteRef"
+    >
+      <template #default="{ item }">
+        <div class="value">{{ item.name }}</div>
+      </template>
+    </el-autocomplete>
+
+    <el-input-tag
+      clearable
+      class="inputTag"
+      v-model="selectedTag"
+      tag-type="primary"
+      tag-effect="plain"
+      @add-tag="tagAdd"
+      @clear="clearTagInput"
+      placeholder="如需新建标签,请直接在此处输入后回车"
+      :save-on-blu="false"
+    >
+      <template #tag="{ value }">
+        <span>{{ handleTagVal(value) }}</span>
+      </template>
+    </el-input-tag>
+    <!--    <el-button @click="submitTagUpdate">提交</el-button>-->
+  </div>
+</template>
+
+<style scoped>
+.inputTag {
+  margin-top: 15px;
+  max-width: 800px;
+  /*height: 50px;*/
+  /*width: 800px;*/
+}
+</style>

+ 109 - 62
src/components/table/PromotionTable.vue

@@ -9,7 +9,7 @@ import {
   TableFieldType,
   type TableProps,
 } from '@/types/Tables/table'
-import { generateUniqueFilename } from '@/utils/common'
+import { generateUniqueFilename, getAssetsImageUrl } from '@/utils/common'
 
 import { Operation, Plus } from '@element-plus/icons-vue'
 
@@ -153,25 +153,27 @@ const sortInfo = reactive<{
   order: null,
 })
 
+const selectedRows = ref<any[]>([])
+
 // 批量操作选中的值
-const batchOper = ref<string>()
-
-// 批量操作的选项数组
-const batchOperList = reactive<
-  Array<{
-    label: string
-    value: string
-  }>
->([
-  {
-    label: '删除',
-    value: 'delete',
-  },
-  {
-    label: '新增',
-    value: 'add',
-  },
-])
+// const batchOper = ref<string>()
+//
+// // 批量操作的选项数组
+// const batchOperList = reactive<
+//   Array<{
+//     label: string
+//     value: string
+//   }>
+// >([
+//   {
+//     label: '删除',
+//     value: 'delete',
+//   },
+//   {
+//     label: '新增',
+//     value: 'add',
+//   },
+// ])
 
 // 导出对话框的可见性
 const exportDialogVisble = ref<boolean>(false)
@@ -205,15 +207,19 @@ const excludeFields = computed<Array<string>>(() => {
 // 分页后的表格数据
 const paginationTableData = computed<Array<TableData>>(() => {
   let result: Array<TableData> = []
-
-  if (props.remotePagination) {
-    result = cacheTableData[paginationConfig.curPage - 1]
-  } else {
-    let curPage = paginationConfig.curPage
-    let pageSize = paginationConfig.curPageSize
-    result = tableData.slice((curPage - 1) * pageSize, curPage * pageSize)
-  }
-
+  let curPage = paginationConfig.curPage
+  let pageSize = paginationConfig.curPageSize
+
+  result = tableData.slice((curPage - 1) * pageSize, curPage * pageSize)
+  // console.log()
+  // if (props.remotePagination) {
+  //   result = cacheTableData[paginationConfig.curPage - 1]
+  // } else {
+  //   let curPage = paginationConfig.curPage
+  //   let pageSize = paginationConfig.curPageSize
+  //   result = tableData.slice((curPage - 1) * pageSize, curPage * pageSize)
+  // }
+  console.log(tableData)
   return result
 })
 
@@ -371,6 +377,7 @@ watch(
     cacheTableData.splice(0, cacheTableData.length)
     tableData.splice(0, tableData.length, ...newData)
 
+    console.log('更新了')
     setCacheTableData(
       props.remotePagination,
       paginationConfig,
@@ -433,10 +440,13 @@ const scrollHandlers = new WeakMap<HTMLElement, ScrollHandler>()
 const vScrollParent = {
   mounted(
     el: HTMLElement,
-    binding: DirectiveBinding<(container: HTMLElement | Window) => () => void>,
+    binding: DirectiveBinding<(container: HTMLElement) => () => void>,
   ) {
     // 获取最近的滚动容器
     const scrollContainer = findScrollParent(el)
+    if (scrollContainer === null) {
+      return
+    }
 
     // 执行回调获取清理函数
     const cleanup = binding.value(scrollContainer)
@@ -527,6 +537,16 @@ const cleanup = () => {
   // window.removeEventListener('scroll', handleScroll)
 }
 
+const handleSelectionChange = (newSelection: any[]) => {
+  console.log(newSelection)
+  selectedRows.value.splice(0, selectedRows.value.length, ...newSelection)
+}
+
+const batchDel = () => {
+  console.log(selectedRows.value)
+  console.log('批量删除')
+}
+
 onUnmounted(() => {
   cleanup()
 })
@@ -555,21 +575,7 @@ defineExpose({
               >添加账户
             </el-button>
           </slot>
-          <slot name="batchOper">
-            <el-select
-              class="batchOper w120"
-              v-model="batchOper"
-              placeholder="批量操作"
-              :disabled="tableLoading"
-            >
-              <el-option
-                v-for="item in batchOperList"
-                :key="item.value"
-                :label="item.label"
-                :value="item.value"
-              />
-            </el-select>
-          </slot>
+          <el-button class="w120">批量操作</el-button>
         </div>
         <div class="tableOperationRight">
           <slot name="exportData" v-if="props.needExport">
@@ -613,10 +619,21 @@ defineExpose({
           </slot>
         </div>
       </div>
+      <div class="batchOperation">
+        <slot name="batchOperationLeft">
+          <div class="occupy"></div>
+        </slot>
+        <slot name="operationBtnContainer" :selectedInfo="selectedRows">
+          <div class="operationBtnContainer">
+            <el-button @click="batchDel" class="w100">删除</el-button>
+          </div>
+        </slot>
+      </div>
       <div class="tableContent" ref="tableContent">
+        <!--       TODO  这里没有用筛选后的数据了,需要补充-->
         <el-table
           v-loading="tableLoading"
-          v-bind="{ ...$attrs, data: paginationTableData }"
+          v-bind="{ ...$attrs, data: tableData }"
           style="width: 100%"
           border
           table-layout="fixed"
@@ -626,7 +643,13 @@ defineExpose({
           ref="tableRef"
           id="table"
           @sort-change="sortChange"
+          @selection-change="handleSelectionChange"
         >
+          <el-table-column
+            type="selection"
+            width="55"
+            :fixed="true"
+          ></el-table-column>
           <template v-for="item in tableFieldsInfo" :key="item.name">
             <el-table-column
               v-if="item.name === 'action'"
@@ -639,11 +662,6 @@ defineExpose({
                 <slot name="operations" :row="scope.row"></slot>
               </template>
             </el-table-column>
-            <!--                        :width="-->
-            <!--                        isFixedField(props.fixedFields, item)-->
-            <!--                        ? tableFieldLWidth-->
-            <!--                        : tableFieldSWidth-->
-            <!--                        "-->
             <el-table-column
               show-overflow-tooltip
               :width="item.fixed ? tableFieldLWidth : tableFieldSWidth"
@@ -653,19 +671,40 @@ defineExpose({
               v-if="item.state && item.name !== 'action'"
               :sortable="item.sort ? 'custom' : false"
             >
-              <template
-                #default="scope"
-                v-if="item.type === TableFieldType.Tag"
-              >
-                <el-tag
-                  v-for="tag in (scope.row['tag'] ?? []).map(
-                    (v: any) => v.name,
-                  )"
-                  style="margin-right: 5px"
-                  type="primary"
+              <template #default="scope">
+                <div v-if="item.type === TableFieldType.Tag">
+                  <el-tag
+                    v-for="tag in (scope.row['tag'] ?? []).map(
+                      (v: any) => v.name,
+                    )"
+                    style="margin-right: 5px"
+                    type="primary"
+                  >
+                    {{ tag === '' ? '默认' : tag }}
+                  </el-tag>
+                </div>
+                <div
+                  class="mediaDisplay"
+                  v-if="
+                    item.type === TableFieldType.Img ||
+                    item.type === TableFieldType.Video
+                  "
                 >
-                  {{ tag === '' ? '空' : tag }}
-                </el-tag>
+                  <el-image
+                    class="tableFieldImg"
+                    :preview-src-list="[scope.row[item.name]]"
+                    :src="scope.row[item.name]"
+                    :preview-teleported="true"
+                  >
+                    <template #error>
+                      <el-image
+                        :src="
+                          getAssetsImageUrl('icon' + '/file/img-load-fail.png')
+                        "
+                      ></el-image>
+                    </template>
+                  </el-image>
+                </div>
               </template>
             </el-table-column>
           </template>
@@ -790,6 +829,14 @@ defineExpose({
   border: 1px solid #e8eaec;
 }
 
+.batchOperation {
+  width: 100%;
+  padding: 10px 20px;
+  display: flex;
+  justify-content: space-between;
+  border: 1px solid #e8eaec;
+}
+
 .tableOperationLeft,
 .tableOperationRight {
   display: flex;

+ 11 - 2
src/config/API.ts

@@ -1,5 +1,10 @@
 const baseURL = 'http://192.168.1.139:8001'
-export const MaterialAPI = {
+
+const LoginAPI = {
+  login: `${baseURL}/user/login`,
+}
+
+const MaterialAPI = {
   getAllTags: `${baseURL}/property/getTags`, // 获取所有标签
   addTag: `${baseURL}/property/setTag`, // 添加标签
   getAllGamesInfo: `${baseURL}/property/gameList`, // 获取所有游戏的pid和gid信息
@@ -9,5 +14,9 @@ export const MaterialAPI = {
   imgList: `${baseURL}/property/imageList`, // 图片列表
   videoList: `${baseURL}/property/videoList`, // 视频列表
   imgFilterInfo: `${baseURL}/property/getImageListHead`, // 图片列表筛选信息
-  videoFilterInfo: `${baseURL}/property/getVideoListHead`,
+  videoFilterInfo: `${baseURL}/property/getVideoListHead`, // 视频列表筛选信息
+  updateImgTags: `${baseURL}/property/updateImageTags`, // 更新图片标签
+  updateVideoTags: `${baseURL}/property/updateVideoTags`,
 }
+
+export { MaterialAPI, LoginAPI }

+ 4 - 3
src/hooks/useTableScroll.ts

@@ -94,7 +94,7 @@ export function useTableScroll(
     }
   }
 
-  const findScrollParent = (el: HTMLElement): HTMLElement | Window => {
+  const findScrollParent = (el: HTMLElement): HTMLElement | null => {
     let parent = el.parentElement
 
     while (parent) {
@@ -116,9 +116,10 @@ export function useTableScroll(
 
       parent = parent.parentElement
     }
+    return null
 
-    // 未找到则返回window
-    return window
+    // // 未找到则返回window
+    // return window
   }
 
   return {

+ 2 - 0
src/types/Tables/table.ts

@@ -30,6 +30,8 @@ enum TableFilterType {
 export enum TableFieldType {
   Default,
   Tag,
+  Video,
+  Img,
 }
 
 // 表格字段的基本信息

+ 60 - 31
src/utils/file/handleVideoFile.ts

@@ -3,6 +3,7 @@ export interface VideoProcessorConfig {
   crossOrigin?: string // 跨域设置(默认'anonymous')
   coverQuality?: number // 封面质量 0-1(默认0.8)
   coverFormat?: 'jpeg' | 'png' // 封面格式(默认'jpeg')
+  fileName?: string // 新增文件名配置
 }
 
 export class VideoProcessor {
@@ -22,6 +23,7 @@ export class VideoProcessor {
       crossOrigin: 'anonymous',
       coverQuality: 0.8,
       coverFormat: 'jpeg',
+      fileName: '',
       ...config,
     }
     this.video = document.createElement('video')
@@ -136,6 +138,64 @@ export class VideoProcessor {
   }
 
   /**
+   * 生成视频封面文件
+   * @param timePoint 截取时间点(秒)
+   */
+  public async generateCoverFile(timePoint = 1): Promise<File> {
+    await this.initialize()
+
+    return new Promise((resolve, reject) => {
+      const targetTime = Math.min(timePoint, this.video.duration)
+
+      const wrappedHandler = () => {
+        try {
+          this.video.removeEventListener('seeked', wrappedHandler)
+          const canvas = document.createElement('canvas')
+          const ctx = canvas.getContext('2d')
+
+          if (!ctx) {
+            throw new Error('无法获取Canvas上下文')
+          }
+
+          canvas.width = this.video.videoWidth
+          canvas.height = this.video.videoHeight
+          ctx.drawImage(this.video, 0, 0, canvas.width, canvas.height)
+
+          // 转换为Blob然后创建File对象
+          canvas.toBlob(
+            blob => {
+              if (!blob) {
+                reject(new Error('生成封面文件失败'))
+                return
+              }
+
+              // 生成文件名
+              const fileName = this.config.fileName || `cover_${Date.now()}`
+              const fileExt = this.config.coverFormat === 'png' ? 'png' : 'jpg'
+              const mimeType = `image/${this.config.coverFormat}`
+
+              // 创建File对象
+              const file = new File([blob], `${fileName}.${fileExt}`, {
+                type: mimeType,
+                lastModified: Date.now(),
+              })
+
+              resolve(file)
+            },
+            `image/${this.config.coverFormat}`,
+            this.config.coverQuality,
+          )
+        } catch (err) {
+          reject(err)
+        }
+      }
+
+      this.video.addEventListener('seeked', wrappedHandler)
+      this.video.currentTime = targetTime
+    })
+  }
+
+  /**
    * 检测视频格式
    */
   private detectVideoFormat(): string {
@@ -191,34 +251,3 @@ export class VideoProcessor {
     this.initializationPromise = null
   }
 }
-
-// // 使用示例
-// const processVideo = async (url: string) => {
-//   const processor = new VideoProcessor(url, {
-//     timeout: 15000,
-//     coverQuality: 0.9
-//   });
-//
-//   try {
-//     // 获取元数据
-//     const info = await processor.getVideoInfo();
-//     console.log('视频信息:', info);
-//
-//     // 生成封面(第5秒)
-//     const cover = await processor.generateCover(5);
-//     console.log('封面Base64:', cover.slice(0, 50) + '...');
-//
-//     return { ...info, cover };
-//   } catch (err) {
-//     console.error('视频处理失败:', err);
-//     throw err;
-//   } finally {
-//     processor.cleanup();
-//   }
-// }
-//
-// // 使用演示
-// const videoUrl = 'https://example.com/sample.mp4';
-// processVideo(videoUrl).then(result => {
-//   console.log('最终处理结果:', result);
-// });

+ 2 - 1
src/views/Login/LoginView.vue

@@ -10,6 +10,7 @@ import router from '@/router'
 import axiosInstance from '@/utils/axios/axiosInstance'
 import MyButton from '@/components/form/MyButton.vue'
 import MyInput from '@/components/form/MyInput.vue'
+import { LoginAPI } from '@/config/API.ts'
 // import CIcon from '@/components/cIcon/CIcon.vue'
 
 const { AllApi, analysisResCode } = useRequest()
@@ -100,7 +101,7 @@ const userLogin = async () => {
   let vaild = validLoginInfo(loginInfo)
   if (vaild) {
     try {
-      let data = await axiosInstance.post(AllApi.userLogin, loginInfo)
+      let data = await axiosInstance.post(LoginAPI.login, loginInfo)
       let result = JSON.parse(JSON.stringify(data))
       await analysisResCode(result, 'login')
       setToken(result.token)

+ 23 - 9
src/views/Material/DrawerTagsView/DrawerBaseInfo.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import { computed, ref } from 'vue'
+import { getAssetsImageUrl } from '@/utils/common'
 
 const imgFieldName = {
   id: 'ID',
@@ -33,11 +34,11 @@ const videoFieldName = {
 
 const props = defineProps<{
   info: any
+  isVideo: boolean
 }>()
 
-const isVideo = ref(false)
 const baseInfo = computed(() => {
-  const fieldData = isVideo.value ? videoFieldName : imgFieldName
+  const fieldData = props.isVideo ? videoFieldName : imgFieldName
   return Object.entries(fieldData).map(([key, label]) => ({
     name: key,
     label,
@@ -107,6 +108,7 @@ const formatOverviewValue = (val: number, config?: DataOverViewItemConfig) => {
 }
 
 console.log(props.info)
+console.log(baseInfo.value)
 </script>
 
 <template>
@@ -114,13 +116,19 @@ console.log(props.info)
     <div class="baseInfo">
       <p class="baseInfoTitle">基本信息</p>
       <div class="mediaPreview">
-        <video v-if="isVideo"></video>
-        <img
-          alt=""
-          class="previewImg"
-          v-else
-          src="http://localhost:8113/img/head/head-boy.png"
-        />
+        <video
+          class="previewVideo"
+          controls
+          :src="info['download']"
+          v-if="isVideo"
+        ></video>
+        <el-image alt="" class="previewImg" v-else :src="info['download']">
+          <template #error>
+            <el-image
+              :src="getAssetsImageUrl('icon' + '/file/img-load-fail.png')"
+            ></el-image>
+          </template>
+        </el-image>
       </div>
 
       <div class="infoDisplay">
@@ -209,6 +217,12 @@ console.log(props.info)
   max-width: 100%;
 }
 
+.previewVideo {
+  min-width: 100%;
+  max-width: 100%;
+  max-height: 150px;
+}
+
 .infoDisplay {
   width: 100%;
   padding-bottom: 12px;

+ 96 - 18
src/views/Material/FilmLibrary.vue

@@ -2,7 +2,6 @@
 import PromotionTable from '@/components/table/PromotionTable.vue'
 import { MaterialAPI } from '@/config/API.ts'
 import { useMaterial } from '@/hooks/Material/useMaterial.ts'
-import router from '@/router'
 import type { ResponseInfo } from '@/types/axios.ts'
 import type { TablePaginationProps } from '@/types/Tables/pagination.ts'
 import {
@@ -23,6 +22,9 @@ import DrawerBaseInfo from '@/views/Material/DrawerTagsView/DrawerBaseInfo.vue'
 import type { BaseTableInfo } from '@/types/Tables/tablePageData.ts'
 import { type TableType, useMenuTable } from '@/hooks/useMenuTable.ts'
 import type TableCustomIndicatorController from '@/utils/localStorage/tableCustomIndicatorController.ts'
+import TagSelect from '@/components/Material/TagSelect.vue'
+import router from '@/router'
+import type { TagItem } from '@/views/Material/types/uploadFileType.ts'
 
 const { getGameInfo, getAllTags } = useMaterial()
 
@@ -32,7 +34,7 @@ const listRef = ref<TableType | null>(null)
 const isVideo = ref<boolean>(false)
 
 const assetTypeName = computed(() => {
-  return isVideo.value ? 'img' : 'video'
+  return isVideo.value ? 'video' : 'img'
 })
 
 const tableInfo = reactive<BaseTableInfo>({
@@ -74,7 +76,28 @@ const activeName = ref(drawerTagConfig[0].name)
 // 自定义指标保存的方案
 const customIndicatorScheme = ref<Array<CustomIndicatorScheme>>([])
 
-const selectRowInfo = ref<any>()
+// 预览行的信息
+const viewRowInfo = ref<any>()
+
+// 保存的表格名
+const saveTableName = '素材'
+
+// 每个表格的自定义方案的IO控制器
+const tableControllers: {
+  [key: string]: TableCustomIndicatorController
+} = {}
+
+// 修改框弹窗控制
+const editDialog = ref<boolean>(false)
+
+// 选中行的信息
+const selectedRowInfo = ref<any[]>([])
+
+// 当前这一行选择了那些tag
+const tagSelected = ref<number[]>([])
+
+// 记录下当前的tag列表,供给弹窗使用
+const allTags = ref<TagItem[]>([])
 
 /**
  * 跳转上传页面
@@ -120,7 +143,7 @@ const updateGameInfo = async () => {
 /**
  * 更新tag标签列表
  */
-const updateTags = async () => {
+const updateTagsOptions = async () => {
   const tags = await getAllTags()
   const options = tags.map(item => {
     return {
@@ -130,11 +153,11 @@ const updateTags = async () => {
   })
   const filterInfo = tableInfo[assetTypeName.value].filters
   const filterTag = (filterInfo as any)['tags']
+
   filterTag['options'] = options
+  allTags.value.splice(0, allTags.value.length, ...tags)
 }
 
-const tableData = reactive<Array<any>>([])
-
 const paginationConfig = reactive<TablePaginationProps>({
   total: 0,
   pageSizeList: [10, 20, 40],
@@ -150,10 +173,11 @@ const updateTableData = async () => {
   paginationConfig.total = result.data.count
   const data = tableInfo[assetTypeName.value].data
   if (paginationConfig.total === 0) {
-    data.splice(0, tableData.length)
+    data.splice(0, data.length)
   } else {
-    data.splice(0, tableData.length, ...result.data.list)
+    data.splice(0, data.length, ...result.data.list)
   }
+  console.log(data)
 }
 
 const updateResolution = async () => {
@@ -179,10 +203,20 @@ const updateResolution = async () => {
 
 const viewDetails = (row: any) => {
   drawerVisible.value = true
-  selectRowInfo.value = row
+  viewRowInfo.value = row
 }
 
-const saveTableName = '素材'
+const delRow = (selectInfo: any[]) => {
+  console.log(selectInfo)
+  console.log('批量删除')
+}
+
+const editTag = (selectInfo: any[]) => {
+  editDialog.value = true
+  selectedRowInfo.value = selectInfo
+  const ids = selectInfo[0].tag?.map((item: any) => item.tag_id) ?? []
+  tagSelected.value.splice(0, tagSelected.value.length, ...ids)
+}
 
 /**
  * @description: 保存的文件名
@@ -192,10 +226,26 @@ const fileName = computed(() => {
   return `${saveTableName}-${assetTypeName.value}`
 })
 
-// 每个表格的自定义方案的IO控制器
-const tableControllers: {
-  [key: string]: TableCustomIndicatorController
-} = {}
+/**
+ * 提交标签修改表单
+ */
+const submitTagUpdate = async () => {
+  const url = isVideo.value
+    ? MaterialAPI.updateVideoTags
+    : MaterialAPI.updateImgTags
+
+  const res = (await axiosInstance.post(url, {
+    id: selectedRowInfo.value[0].id,
+    tags: tagSelected.value,
+  })) as ResponseInfo
+  if (res.code === 0) {
+    ElMessage.success('更新成功')
+    editDialog.value = false
+    await updateTableData()
+  } else {
+    ElMessage.error('更新失败')
+  }
+}
 
 const { changeScheme, updateCustomIndicator, initData } = useMenuTable(
   saveTableName,
@@ -213,7 +263,7 @@ const { changeScheme, updateCustomIndicator, initData } = useMenuTable(
 const updateFilterInfo = async () => {
   await updateResolution()
   await updateGameInfo()
-  await updateTags()
+  await updateTagsOptions()
 }
 
 const changeAssetType = async () => {
@@ -271,11 +321,25 @@ onMounted(async () => {
         <template #operations="{ row }">
           <div>
             <el-button text type="primary" @click="viewDetails(row)"
-              >详情</el-button
-            >
+              >详情
+            </el-button>
             <el-button text type="success">下载</el-button>
           </div>
         </template>
+        <template #operationBtnContainer="{ selectedInfo }">
+          <div>
+            <el-button
+              :disabled="selectedInfo.length <= 0"
+              @click="delRow(selectedInfo)"
+              >删除
+            </el-button>
+            <el-button
+              :disabled="selectedInfo.length !== 1"
+              @click="editTag(selectedInfo)"
+              >修改标签
+            </el-button>
+          </div>
+        </template>
       </PromotionTable>
     </div>
     <el-drawer
@@ -293,10 +357,24 @@ onMounted(async () => {
           :label="item.label"
           :name="item.name"
         >
-          <component :info="selectRowInfo" :is="item.component"></component>
+          <component
+            :isVideo="isVideo"
+            :info="viewRowInfo"
+            :is="item.component"
+          ></component>
         </el-tab-pane>
       </el-tabs>
     </el-drawer>
+    <el-dialog
+      v-model="editDialog"
+      title="修改tag"
+      width="800"
+      :destroy-on-close="true"
+      :close-on-click-modal="false"
+    >
+      <tag-select v-model:selected-tag="tagSelected"></tag-select>
+      <el-button @click="submitTagUpdate">提交</el-button>
+    </el-dialog>
   </div>
 </template>
 

+ 58 - 116
src/views/Material/UploadAsset.vue

@@ -7,7 +7,6 @@ import type { ResponseInfo } from '@/types/axios.ts'
 
 import axiosInstance from '@/utils/axios/axiosInstance.ts'
 import type {
-  AddTagRes,
   GameData,
   GameSelectItem,
   ImgUploadRes,
@@ -21,16 +20,18 @@ import type {
   UploadFile,
   UploadFiles,
   UploadInstance,
+  UploadRawFile,
   UploadRequestOptions,
 } from 'element-plus'
 import { ElMessageBox } from 'element-plus'
 import { nextTick, onMounted, type Reactive, reactive, ref, toRaw } from 'vue'
 import { VideoProcessor } from '@/utils/file/handleVideoFile.ts'
 import { ImageProcessor } from '@/utils/file/handleImgFile.ts'
+import TagSelect from '@/components/Material/TagSelect.vue'
 
 const { getAllTags } = useMaterial()
 const { isValidFile, handleFile } = useFileUpload()
-const { getGameInfo, createFilter } = useMaterial()
+const { getGameInfo } = useMaterial()
 
 // 表单数据
 const uploadForm = reactive<UploadAssetForm>({
@@ -45,6 +46,8 @@ const uploadForm = reactive<UploadAssetForm>({
     width: 0,
     height: 0,
   },
+  headImgPath: '',
+  videoType: 1,
 })
 
 // 表单规则
@@ -68,7 +71,7 @@ const gameSelect = reactive<GameSelectItem[]>([]) // 游戏级联选择框数据
 const uploadRef = ref<UploadInstance>()
 const fileFormRef = ref<FormInstance>()
 const tagInput = ref('') // 标签输入框数据
-const autocompleteRef = ref()
+
 const isVideo = ref(false) // 当前上传的时候是视频
 const limit = 1 // 限制上传文件数量
 const allTags: Array<TagItem> = [] // 所有标签
@@ -78,6 +81,7 @@ const previewUrl = ref('')
 const previewVideoRef = ref<HTMLVideoElement>()
 
 const coverUrl = ref('') // 封面图片地址
+const coverFile = ref<File | null>(null) // 封面图片文件
 
 // 级联选择框属性配置
 const cascadePropsConfig = {
@@ -120,31 +124,6 @@ const generateGameSelect = (
 }
 
 /**
- * 标签搜索
- * @param queryString 搜索值
- * @param cb 回调
- */
-const tagSearch = async (queryString: string, cb: any) => {
-  const result = queryString
-    ? allTags.filter(createFilter(queryString))
-    : allTags
-
-  cb(result)
-}
-
-/**
- * 处理标签选择后的事件
- * @param tags 选中的标签
- */
-const handleTagSelect = (tags: TagItem) => {
-  uploadForm.tags.push(tags.id)
-  // 需要在下一帧出发blur,否则无法选中
-  nextTick(() => {
-    autocompleteRef.value.blur()
-  })
-}
-
-/**
  * 当文件列表发生变化时的处理事件
  *
  * @param file 选择的文件
@@ -170,12 +149,14 @@ const handleFileChange = (file: UploadFile, fileList: UploadFiles) => {
     // 重置文件状态保证可重复上传
     fileList.forEach(f => (f.status = 'ready'))
 
-    console.log(file)
     // 如果是视频的话,还需要生成封面
     if (isVideo.value) {
       nextTick(async () => {
         const videoProcessor = new VideoProcessor(previewUrl.value)
         const { width, height } = await videoProcessor.getVideoInfo()
+        coverFile.value = await videoProcessor.generateCoverFile()
+        // await uploadFormFile(file, true)
+        // isUploading.value = false
         coverUrl.value = await videoProcessor.generateCover()
         uploadForm.resolution = {
           width,
@@ -236,32 +217,54 @@ const submitAsset = async (formEl: FormInstance | undefined) => {
 }
 
 /**
+ * 上传单个文件,获得回显
+ * @param file 上传的文件
+ * @returns 回显信息,上传失败则是null
+ */
+const uploadSingleFile = async (
+  file: UploadRawFile | File,
+): Promise<ResFileInfo | null> => {
+  const formData = new FormData()
+  formData.append('file', file)
+  const result = (await axiosInstance.postForm(
+    MaterialAPI.uploadAsset,
+    formData,
+  )) as ResFileInfo
+  if (result.code !== 0) {
+    ElMessage.error('上传素材失败')
+    return null
+  }
+  return result
+}
+
+/**
  * 文件上传
  *
  * 表单提交需要先拿到文件上传成功后的回显,所以文件上传成功后才会执行表单的提交
  * @param options 上传文件信息
  */
-const uploadFile = async (options: UploadRequestOptions) => {
-  isUploading.value = true
+const uploadFormFile = async (options: UploadRequestOptions) => {
   if (!options || !options.file) {
     ElMessage.warning('请先上传文件')
-    isUploading.value = false
     return
   }
+  isUploading.value = true
+  // 如果有封面图片,需要上传封面图片
+  if (coverFile.value !== null) {
+    const res = await uploadSingleFile(coverFile.value)
+    if (!res) {
+      isUploading.value = false
+      return false
+    }
+    uploadForm.headImgPath = res.path
+  }
   const file = options.file
-  const formData = new FormData()
-  formData.append('file', file)
-  const result = (await axiosInstance.postForm(
-    MaterialAPI.uploadAsset,
-    formData,
-  )) as ResFileInfo
-  if (result.code !== 0) {
-    ElMessage.error('上传素材失败')
+  const res = await uploadSingleFile(file)
+  if (!res) {
     isUploading.value = false
     return false
   }
-
-  const { md5, path } = result
+  const { md5, path } = res
   uploadForm.md5 = md5
   uploadForm.filePath = path
 
@@ -269,6 +272,15 @@ const uploadFile = async (options: UploadRequestOptions) => {
   await submitForm()
 }
 
+// 判断横版还是竖版,返回数字
+const getVideoType = (): 1 | 2 => {
+  const { width, height } = uploadForm.resolution
+  if (width > height) {
+    return 1
+  }
+  return 2
+}
+
 const submitForm = async () => {
   const url = isVideo.value ? MaterialAPI.videoSubmit : MaterialAPI.imgSubmit
   const nFormData = JSON.parse(JSON.stringify(toRaw(uploadForm)))
@@ -276,6 +288,7 @@ const submitForm = async () => {
   // 区分视频和图片,他们需要的字段不同
   if (isVideo.value) {
     nFormData.videoPath = nFormData.filePath
+    nFormData.videoType = getVideoType()
     // 对于视频上传需要删除掉九宫格字段信息
     delete nFormData.isImg9
   } else {
@@ -300,6 +313,7 @@ const submitForm = async () => {
   uploadRef.value?.clearFiles()
   tagInput.value = ''
   fileFormRef.value?.resetFields()
+  coverFile.value = null
 }
 
 /**
@@ -310,47 +324,6 @@ const goBack = () => {
 }
 
 /**
- * 新增tag
- * @param val 传新增值
- */
-const tagAdd = async (val: string) => {
-  const isExist = allTags.find(item => item.name === val)
-  const newTagName = val
-  // 首先要判断是否已经存在这个标签,不存在则需要先走新建流程,所以不要直接添加进去,先移除
-  uploadForm.tags.splice(uploadForm.tags.length - 1, 1)
-  let id = -1
-  if (!isExist) {
-    const confirm = await ElMessageBox.confirm('不存在改标签,是否创建?', {
-      confirmButtonText: '确定',
-      cancelButtonText: '取消',
-      type: 'warning',
-    })
-    if (!confirm) return
-    const res = (await axiosInstance.post(MaterialAPI.addTag, {
-      name: newTagName,
-    })) as AddTagRes
-    if (res.code !== 0) {
-      ElMessage.error('创建标签失败')
-      return
-    }
-    ElMessage.success('创建标签成功')
-
-    await updateTags()
-    id = res.data.id
-  } else {
-    id = isExist.id
-  }
-  uploadForm.tags.push(id)
-}
-
-/**
- * 清空标签输入框
- */
-const clearTagInput = () => {
-  uploadForm.tags = []
-}
-
-/**
  * 更新tag标签列表
  */
 const updateTags = async () => {
@@ -425,38 +398,7 @@ onMounted(async () => {
           </el-form-item>
           <el-form-item label="标签" prop="tags">
             <div class="tagContainer">
-              <!--     这里需要修改value-key,否则会导致警告         -->
-              <el-autocomplete
-                :debounce="200"
-                v-model="tagInput"
-                value-key="name"
-                :fetch-suggestions="tagSearch"
-                clearable
-                class="inline-input w220"
-                placeholder="请输入标签以搜索"
-                @select="handleTagSelect"
-                ref="autocompleteRef"
-              >
-                <template #default="{ item }">
-                  <div class="value">{{ item.name }}</div>
-                </template>
-              </el-autocomplete>
-              <el-input-tag
-                clearable
-                class="inputTag"
-                v-model="uploadForm.tags"
-                tag-type="primary"
-                tag-effect="plain"
-                @add-tag="tagAdd"
-                @clear="clearTagInput"
-                placeholder="如需新建标签,请直接在此处输入后回车"
-              >
-                <template #tag="{ value }">
-                  <span>{{
-                    allTags.find(item => item.id === value)?.name
-                  }}</span>
-                </template>
-              </el-input-tag>
+              <tag-select v-model:selected-tag="uploadForm.tags"></tag-select>
             </div>
           </el-form-item>
           <!--          :limit="limit"-->
@@ -469,7 +411,7 @@ onMounted(async () => {
                 list-type="picture"
                 :on-change="handleFileChange"
                 ref="uploadRef"
-                :http-request="uploadFile"
+                :http-request="uploadFormFile"
               >
                 <template #file="{ file }">
                   <div class="fileInfo">

+ 6 - 4
src/views/Material/config/FimLibraryConfig.ts

@@ -61,7 +61,7 @@ const videoFilterInfo = reactive<FilterInfo>({
     options: [],
   },
   search: {
-    label: '资源名',
+    label: '素材名',
     name: 'search',
     type: TableFilterType.Search,
     value: 'search',
@@ -112,7 +112,7 @@ const imgFilterInfo = reactive<FilterInfo>({
     options: [],
   },
   search: {
-    label: '资源名',
+    label: '素材名',
     name: 'search',
     type: TableFilterType.Search,
     value: 'search',
@@ -173,9 +173,10 @@ const imgFields: Array<BaseFieldItem<BaseFieldInfo>> = [
       },
       {
         label: '预览图片',
-        name: 'local_path',
+        name: 'download',
         state: true,
         fixed: false,
+        type: TableFieldType.Img,
       },
       {
         label: '分辨率',
@@ -239,9 +240,10 @@ const videoFields: Array<BaseFieldItem<BaseFieldInfo>> = [
       },
       {
         label: '预览视频',
-        name: 'local_path',
+        name: 'headImgDownload',
         state: true,
         fixed: false,
+        type: TableFieldType.Video,
       },
       {
         label: '分辨率',

+ 2 - 1
src/views/Material/types/uploadFileType.ts

@@ -8,7 +8,8 @@ interface UploadAssetForm {
   isImg9: number // 是否是九宫格图片
   imgPath?: string // 图片路径
   videoPath?: string // 视频路径
-  videoCoverImg?: string // 视频封面图片
+  headImgPath?: string // 视频封面图片地址
+  videoType?: 1 | 2 // 横版竖版 	1 横版 2竖版
   // 分辨率
   resolution: {
     width: number