Bladeren bron

feat(UploadAsset): 完善上传表单

fxs 2 maanden geleden
bovenliggende
commit
adf9c8e283

+ 1 - 0
components.d.ts

@@ -13,6 +13,7 @@ declare module 'vue' {
     CustomIndicatorDialog: typeof import('./src/components/dialog/customIndicatorDialog.vue')['default']
     ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
     ElButton: typeof import('element-plus/es')['ElButton']
+    ElCascader: typeof import('element-plus/es')['ElCascader']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
     ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
     ElCol: typeof import('element-plus/es')['ElCol']

+ 5 - 5
src/components/echarts/HomeAnalysisLine.vue

@@ -1,12 +1,12 @@
 <script setup lang="ts">
-import type { EChartsOption, SeriesOption } from 'echarts'
 import type { LegendInfo } from '@/types/echarts/homeAnalysisChart'
+import { debounceFunc } from '@/utils/common'
+import type { EChartsOption, SeriesOption } from 'echarts'
+import type { YAXisOption } from 'echarts/types/dist/shared'
+import { cloneDeep } from 'lodash'
 
 import { nextTick, onMounted, ref, shallowRef, watch } from 'vue'
-import { cloneDeep } from 'lodash'
-import { debounceFunc } from '@/utils/common'
 import echarts from './index'
-import type { YAXisOption } from 'echarts/types/dist/shared'
 
 interface Props {
   loading: boolean
@@ -28,7 +28,7 @@ const formatterTooltip = (params: Object | Array<any>) => {
     width:10px;height:10px;left:5px;background-color:`
     let result = `<span style="font-weight:bold;">${params[0].axisValueLabel}</span>`
     params.map((item, index) => {
-      // props.legend[index].color
+      // cascadePropsConfig.legend[index].color
       let data = `${circle}${props.legend[index].color}"></span>
     <span >
     <span  style="display: inline-block;  box-sizing: border-box;

+ 10 - 10
src/components/promotion/MenuTable.vue

@@ -1,19 +1,19 @@
 <script setup lang="ts">
+import { useDate } from '@/hooks/useDate'
+
+import { useMenuTable } from '@/hooks/useMenuTable'
 import type { BaseMenu } from '@/types/Promotion/Menu'
-import type { CustomIndicatorScheme } from '@/types/Tables/table'
-import type { BaseTableInfo } from '@/types/Tables/tablePageData'
 import type { MenuTableReq } from '@/types/Tables/MenuTable/menuTableReq'
+import type { Operations } from '@/types/Tables/Operations/operations'
 import type { TablePaginationProps } from '@/types/Tables/pagination'
+import type { CustomIndicatorScheme } from '@/types/Tables/table'
+import type { BaseTableInfo } from '@/types/Tables/tablePageData'
 
-import Menu from '../navigation/Menu.vue'
-import Table from '../table/Table.vue'
-
-import { useMenuTable } from '@/hooks/useMenuTable'
+import TableCustomIndicatorController from '@/utils/localStorage/tableCustomIndicatorController'
 import { computed, onMounted, ref } from 'vue'
-import { useDate } from '@/hooks/useDate'
 
-import TableCustomIndicatorController from '@/utils/localStorage/tableCustomIndicatorController'
-import type { Operations } from '@/types/Tables/Operations/operations'
+import Menu from '../navigation/Menu.vue'
+import Table from '../table/Table.vue'
 
 // 自定义表格的类型
 type TableType = InstanceType<typeof Table>
@@ -33,7 +33,7 @@ interface MenuTableProps {
 
 const { disableDate, shortcuts } = useDate()
 
-// props
+// cascadePropsConfig
 const props = withDefaults(defineProps<MenuTableProps>(), {
   excludeFields: () => ({}),
 })

+ 3 - 3
src/components/statisticCard/StatisticCard.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
-import { computed } from 'vue'
 import { useNumber } from '@/hooks/useNumber'
+import { computed } from 'vue'
 import AnimationNumber from '../animationNumber/AnimationNumber.vue'
 
 interface Props {
@@ -15,7 +15,7 @@ interface Props {
 
 const { formatterNumberGroup } = useNumber()
 
-const props = withDefaults(defineProps<Props>(), {
+const cascadePropsConfig = withDefaults(defineProps<Props>(), {
   value: 0,
   duration: 500,
   transition: false,
@@ -40,7 +40,7 @@ const formatterValue = computed(() => {
     <div class="staticCard">
       <div class="title">
         <slot name="title">
-          {{ props.title }}
+          {{ cascadePropsConfig.title }}
         </slot>
       </div>
       <div class="content">

+ 9 - 0
src/config/API.ts

@@ -0,0 +1,9 @@
+const baseURL = 'http://192.168.1.139:8001'
+export const MaterialAPI = {
+  getAllTags: `${baseURL}/property/getTags`, // 获取所有标签
+  addTag: `${baseURL}/property/setTag`, // 添加标签
+  getAllGamesInfo: `${baseURL}/property/gameList`, // 获取所有游戏的pid和gid信息
+  uploadAsset: `${baseURL}/property/upload`, // 上传文件
+  imgSubmit: `${baseURL}/property/imageSet`, // 图片提交
+  videoSubmit: `${baseURL}/property/videoSet`, // 视频提交
+}

+ 127 - 0
src/hooks/File/useFileUpload.ts

@@ -0,0 +1,127 @@
+import { MaterialAPI } from '@/config/API.ts'
+import axiosInstance from '@/utils/axios/axiosInstance.ts'
+import type {
+  TagsRes,
+  UploadAssetForm,
+} from '@/views/Material/types/uploadFileType.ts'
+
+import type {
+  UploadFile,
+  UploadFiles,
+  UploadRawFile,
+  UploadUserFile,
+} from 'element-plus'
+import { type Reactive, type Ref } from 'vue'
+
+export function useFileUpload() {
+  function isValidFile(file: UploadFile | UploadRawFile) {
+    // 检查文件对象和 raw 是否存在
+    if (!file) {
+      return false
+    }
+    let resultFile: UploadRawFile | null = null
+    if ('raw' in file) {
+      resultFile = file['raw'] as UploadRawFile
+    } else {
+      resultFile = file as UploadRawFile
+    }
+
+    // 支持的文件类型
+    const supportedTypes = ['image/png', 'image/jpeg', 'video/mp4', 'video/avi']
+
+    // 最大文件大小:100MB
+    const maxSize = 100 * 1024 * 1024 // 104,857,600 字节
+
+    // 获取文件的类型和大小
+    const fileType = resultFile.type
+    const fileSize = resultFile.size
+
+    // 检查类型是否支持且大小是否在限制内
+    return supportedTypes.includes(fileType) && fileSize <= maxSize
+  }
+
+  function getFileCategory(fileType: string): 'img' | 'video' | 'unknown' {
+    // 检查输入是否为字符串
+    if (typeof fileType !== 'string') {
+      console.error('fileType must be a string')
+      return 'unknown'
+    }
+
+    // 判断文件类型
+    if (fileType.startsWith('image/')) {
+      return 'img'
+    } else if (fileType.startsWith('video/')) {
+      return 'video'
+    } else {
+      return 'unknown'
+    }
+  }
+
+  const handleFile = (
+    file: UploadFile | UploadRawFile,
+    fileList: UploadUserFile[] | UploadFiles,
+    isVideo: Ref<boolean>,
+    formData: Reactive<UploadAssetForm>,
+  ) => {
+    console.log(JSON.parse(JSON.stringify(formData)))
+    if (!isValidFile(file)) {
+      fileList.splice(1)
+      ElMessage.warning('上传文件超出限制或不是规定文件')
+      return false
+    }
+    const fileType =
+      'raw' in file
+        ? (file as UploadFile).raw?.type
+        : (file as UploadRawFile).type
+    if (!fileType) {
+      ElMessage.warning('请上传有效文件')
+      return false
+    }
+    const type = getFileCategory(fileType)
+    if (!type || type === 'unknown') {
+      ElMessage.warning('文件类型无效')
+      return
+    }
+    isVideo.value = type === 'video'
+    // 仅用于过表单检测
+    formData.filePath = file.name
+    console.log(JSON.parse(JSON.stringify(formData)))
+    // nextTick(() => {
+    //   formRef.value?.validateField('filePath')
+    // })
+  }
+
+  const getAllTags = async () => {
+    const res = (await axiosInstance.post(
+      MaterialAPI.getAllTags,
+      {},
+    )) as TagsRes
+    if (res.code !== 0) {
+      ElMessage.error('获取标签失败')
+      return []
+    }
+    return res.data
+  }
+
+  const validateFile = (rule: any, value: any, callback: any) => {
+    console.log(rule)
+    console.log(value)
+    if (value === '') {
+      callback(new Error('Please input the password'))
+    } else {
+      // if (ruleForm.checkPass !== '') {
+      //   if (!ruleFormRef.value) return
+      //   ruleFormRef.value.validateField('checkPass')
+      // }
+      callback()
+    }
+  }
+
+  return {
+    isValidFile,
+    getFileCategory,
+    handleFile,
+    getAllTags,
+    validateFile,
+  }
+}

+ 270 - 82
src/views/Material/UploadAsset.vue

@@ -1,34 +1,38 @@
 <script setup lang="ts">
-import { nextTick, reactive, ref } from 'vue'
-import type { FormRules } from 'element-plus'
-
-import axiosInstance from '@/utils/axios/axiosInstance.ts'
-import { useRequest } from '@/hooks/useRequest.ts'
+import { MaterialAPI } from '@/config/API.ts'
+import { useFileUpload } from '@/hooks/File/useFileUpload.ts'
 import type { ResponseInfo } from '@/types/axios.ts'
 
-const { AllApi } = useRequest()
-
-interface UploadAssetForm {
-  name: string
-  tag: Array<string>
-  gid: string
-  filePath: string
-  md5: string
-}
-
-interface GameInfo {
-  gid: string
-  label: string
-}
+import axiosInstance from '@/utils/axios/axiosInstance.ts'
+import type {
+  AddTagRes,
+  GameData,
+  GameSelectItem,
+  ResFileInfo,
+  TagItem,
+  UploadAssetForm,
+} from '@/views/Material/types/uploadFileType.ts'
+import { Plus } from '@element-plus/icons-vue'
+import type {
+  FormInstance,
+  FormRules,
+  UploadFile,
+  UploadFiles,
+  UploadInstance,
+  UploadRawFile,
+  UploadRequestOptions,
+  UploadUserFile,
+} from 'element-plus'
+import { ElMessageBox, genFileId } from 'element-plus'
+import { nextTick, onMounted, reactive, ref, toRaw } from 'vue'
 
-interface TagInfo {
-  value: string
-}
+const { isValidFile, handleFile, getAllTags } = useFileUpload()
 
 const uploadForm = reactive<UploadAssetForm>({
   name: '',
-  tag: [],
+  tags: [],
   gid: '',
+  pid: '',
   filePath: '',
   md5: '',
 })
@@ -38,61 +42,228 @@ const rules = reactive<FormRules<UploadAssetForm>>({
     { required: true, message: '请输入创意名', trigger: 'blur' },
     { min: 3, max: 5, message: '3-5个字符', trigger: 'blur' },
   ],
-  tag: [{ required: true, message: '请选择标签', trigger: 'blur' }],
-  gid: [{ required: true, message: '请选择游戏', trigger: 'blur' }],
+  tags: [
+    { required: true, message: '请选择标签', trigger: 'change' },
+    { required: true, message: '请选择标签', trigger: 'blur' },
+  ],
+  gid: [{ required: true, message: '请选择游戏', trigger: 'change' }],
+  filePath: [{ required: true, message: '请选择素材', trigger: 'change' }],
 })
 
-const gameInfoList = reactive<Array<GameInfo>>([
-  {
-    gid: '1',
-    label: '游戏1',
-  },
-  {
-    gid: '2',
-    label: '游戏12',
-  },
-  {
-    gid: '3',
-    label: '游戏13',
-  },
-])
+let gameInfoList: GameData = {}
+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> = []
 
-const tagSearch = async (queryString: string, cb: any) => {
-  const resInfo = (await axiosInstance.get(AllApi.tags)) as ResponseInfo
-  if (resInfo.code !== 0) {
-    return []
+const cascadePropsConfig = {
+  expandTrigger: 'hover' as const,
+}
+
+const gameSelectChange = (value: string[]) => {
+  uploadForm.pid = value[0]
+  uploadForm.gid = value[1]
+}
+
+const generateGameSelect = () => {
+  gameSelect.splice(0, gameSelect.length)
+  for (let [k, v] of Object.entries(gameInfoList)) {
+    const children = v.map(item => {
+      return {
+        value: item.gid,
+        label: item.gameName,
+      }
+    })
+    gameSelect.push({
+      value: k,
+      label: k === '' ? '默认' : k,
+      children,
+    })
   }
-  const results = resInfo.data as Array<TagInfo>
+}
 
+const tagSearch = async (queryString: string, cb: any) => {
   const result = queryString
-    ? results.filter(createFilter(queryString))
-    : results
+    ? allTags.filter(createFilter(queryString))
+    : allTags
 
   cb(result)
 }
 
 const createFilter = (queryString: string) => {
-  return (restaurant: TagInfo) => {
+  return (restaurant: TagItem) => {
     return (
-      restaurant.value.toLowerCase().indexOf(queryString.toLowerCase()) === 0
+      restaurant.name.toLowerCase().indexOf(queryString.toLowerCase()) === 0
     )
   }
 }
 
-const handleTagSelect = (tag: TagInfo) => {
-  uploadForm.tag.push(tag.value)
-  tagInput.value = ''
+const handleTagSelect = (tags: TagItem) => {
+  uploadForm.tags.push(tags.id)
   nextTick(() => {
     autocompleteRef.value.blur()
   })
 }
 
-const goBack = () => {
-  console.log('back')
+const handleExceed = async (
+  files: UploadRawFile[],
+  fileList: UploadUserFile[],
+) => {
+  const confirm = await ElMessageBox.confirm(
+    '上传将覆盖前一个文件,是否继续上传?',
+    {
+      confirmButtonText: '继续上传',
+      cancelButtonText: '取消',
+      type: 'warning',
+    },
+  )
+  const file = files[0]
+  if (!confirm) return false
+  handleFile(file, fileList, isVideo, uploadForm)
+  nextTick(() => {
+    fileFormRef.value?.validateField('filePath')
+  })
+  uploadRef.value!.clearFiles()
+  file.uid = genFileId()
+  uploadRef.value!.handleStart(file)
 }
+
+const handleFileChange = (file: UploadFile, fileList: UploadFiles) => {
+  if (!file || !file.raw) {
+    ElMessage.warning('请传入合法文件')
+    return
+  }
+  if (!isValidFile(file)) {
+    fileList.splice(1)
+    ElMessage.warning('上传文件超出限制或不是规定文件')
+  }
+
+  handleFile(file, fileList, isVideo, uploadForm)
+  nextTick(() => {
+    fileFormRef.value?.validateField('filePath')
+  })
+  // 在第一次上传成功后,文件状态会被设置为success,此时是无法重新提交文件上传的,故需要手动重置
+  fileList.forEach(file => {
+    file.status = 'ready'
+  })
+
+  console.log(uploadForm)
+}
+
+const submitAsset = async (formEl: FormInstance | undefined) => {
+  if (!formEl) return
+  const isValid = await formEl.validate()
+  if (!isValid) {
+    ElMessage.warning('请填写完整信息')
+    return
+  }
+  uploadRef.value?.submit()
+}
+
+const submitForm = async () => {
+  const url = isVideo.value ? MaterialAPI.videoSubmit : MaterialAPI.imgSubmit
+  const nFormData = JSON.parse(JSON.stringify(toRaw(uploadForm)))
+  if (isVideo.value) {
+    nFormData.videoPath = nFormData.filePath
+  } else {
+    nFormData.imgPath = nFormData.filePath
+  }
+  delete nFormData.filePath
+
+  // TODO 完善图片和视频的返回值类型
+  const result = await axiosInstance.post(url, nFormData)
+  if (result.code !== 0) {
+    ElMessage.error('上传素材失败')
+    return
+  }
+  ElMessage.success('上传素材成功')
+  uploadRef.value?.clearFiles()
+  tagInput.value = ''
+  fileFormRef.value?.resetFields()
+}
+
+const uploadFile = async (options: UploadRequestOptions) => {
+  if (!options || !options.file) {
+    ElMessage.warning('请先上传文件')
+    return
+  }
+  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('上传素材失败')
+    return false
+  }
+
+  const { md5, path } = result
+  uploadForm.md5 = md5
+  uploadForm.filePath = path
+  await submitForm()
+}
+
+const goBack = () => {}
+
+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 = []
+}
+
+const updateTags = async () => {
+  const tags = await getAllTags()
+  allTags.splice(0, allTags.length, ...tags)
+}
+
+const updateGameInfo = async () => {
+  const res = (await axiosInstance.post(
+    MaterialAPI.getAllGamesInfo,
+    {},
+  )) as ResponseInfo
+  console.log(res)
+  gameInfoList = res.data as GameData
+  generateGameSelect()
+}
+
+onMounted(async () => {
+  await updateTags()
+  await updateGameInfo()
+})
 </script>
 
 <template>
@@ -109,6 +280,7 @@ const goBack = () => {
           :rules="rules"
           :model="uploadForm"
           :label-position="'left'"
+          ref="fileFormRef"
           label-width="auto"
           class="uploadForm"
         >
@@ -120,36 +292,47 @@ const goBack = () => {
             />
           </el-form-item>
           <el-form-item label="游戏" prop="gid">
-            <el-select
-              class="w220"
+            <el-cascader
               v-model="uploadForm.gid"
-              placeholder="选择游戏"
-            >
-              <el-option
-                v-for="item in gameInfoList"
-                :label="item.label"
-                :value="item.gid"
-              />
-            </el-select>
+              :options="gameSelect"
+              :props="cascadePropsConfig"
+              @change="gameSelectChange"
+            />
           </el-form-item>
-          <el-form-item label="标签" prop="tag">
+          <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.tag"
+                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>
             </div>
           </el-form-item>
           <el-form-item label="文件" prop="filePath">
@@ -157,39 +340,40 @@ const goBack = () => {
               <el-upload
                 class="uploadContainer"
                 drag
-                multiple
                 :auto-upload="false"
                 list-type="picture"
+                :limit="limit"
+                :on-change="handleFileChange"
+                :on-exceed="handleExceed"
+                ref="uploadRef"
+                :http-request="uploadFile"
               >
                 <el-icon class="el-icon--upload">
                   <upload-filled />
                 </el-icon>
                 <div class="el-upload__text">
                   <p>将文件或文件夹拖到此处,或点击上传文件</p>
+                  <p class="upload-tips">1.仅支持图片和视频文件,单个上传</p>
                   <p class="upload-tips">
-                    1.仅支持图片和视频文件,上传添加不超过500个
-                  </p>
-                  <p class="upload-tips">
-                    2.支持图片格式:png、jpg、jpeg、gif,不超过100MB;支持视频格式:mp4、mpeg、3pg、avi、mov,不超过5G
+                    2.支持图片格式:png、jpg、jpeg,不超过100MB;支持视频格式:mp4、avi,不超过100MB
                   </p>
                   <p class="upload-tips">
                     3.素材上传后需对尺寸、码率等进行分析,约1-3分钟后方可用于投放
                   </p>
                 </div>
-                <template #file="{ file }">
-                  <div>
-                    <img
-                      class="el-upload-list__item-thumbnail"
-                      :src="file.url"
-                      alt=""
-                    />
-                    <div>撒都</div>
-                  </div>
-                </template>
               </el-upload>
             </div>
           </el-form-item>
         </el-form>
+        <div class="fileFooter">
+          <el-button
+            color="#197AFB"
+            :icon="Plus"
+            class="el-button-mini"
+            @click="submitAsset(fileFormRef)"
+            >确定
+          </el-button>
+        </div>
       </div>
     </div>
   </div>
@@ -254,4 +438,8 @@ const goBack = () => {
 .upload-tips {
   color: #b6b7b7;
 }
+
+.fileItem {
+  display: flex;
+}
 </style>

+ 62 - 0
src/views/Material/types/uploadFileType.ts

@@ -0,0 +1,62 @@
+interface UploadAssetForm {
+  name: string
+  tags: Array<number>
+  gid: string
+  pid: string
+  filePath: string
+  md5: string
+  imgPath?: string
+  videoPath?: string
+}
+
+type GameItem = {
+  pid: string
+  gid: string
+  gameName: string
+}
+
+type GameData = {
+  [category: string]: GameItem[]
+}
+
+interface GameSelectItem {
+  value: string
+  label: string
+  children?: GameSelectItem[]
+}
+
+interface ResFileInfo {
+  code: number
+  md5: string
+  path: string
+}
+
+interface TagItem {
+  id: number
+  name: string
+  createdAt: string
+}
+
+interface TagsRes {
+  code: number
+  count: number
+  data: TagItem[]
+}
+
+interface AddTagRes {
+  code: number
+  data: {
+    id: number
+  }
+}
+
+export type {
+  UploadAssetForm,
+  GameItem,
+  GameData,
+  GameSelectItem,
+  ResFileInfo,
+  TagItem,
+  TagsRes,
+  AddTagRes,
+}

File diff suppressed because it is too large
+ 0 - 0
stats.html


Some files were not shown because too many files changed in this diff