Просмотр исходного кода

feat: 添加新的上传组件

fan 2 лет назад
Родитель
Сommit
01b65b4180

+ 3 - 0
src/components/XTUpload/index.ts

@@ -0,0 +1,3 @@
+import XTUpload from './src/XTUpload.vue';
+
+export { XTUpload };

+ 104 - 0
src/components/XTUpload/src/FileList.vue

@@ -0,0 +1,104 @@
+<script lang="tsx">
+  import { defineComponent, CSSProperties, watch, nextTick } from 'vue';
+  import { fileListProps } from './props';
+  import { isFunction } from '/@/utils/is';
+  import { useModalContext } from '/@/components/Modal/src/hooks/useModalContext';
+
+  export default defineComponent({
+    name: 'FileList',
+    props: fileListProps,
+    setup(props) {
+      const modalFn = useModalContext();
+      watch(
+        () => props.dataSource,
+        () => {
+          nextTick(() => {
+            modalFn?.redoModalHeight?.();
+          });
+        },
+      );
+      return () => {
+        const { columns, actionColumn, dataSource } = props;
+        const columnList = [...columns, actionColumn];
+        return (
+          <table class="file-table">
+            <colgroup>
+              {columnList.map(item => {
+                const { width = 0, dataIndex } = item;
+                const style: CSSProperties = {
+                  width: `${width}px`,
+                  minWidth: `${width}px`,
+                };
+                return <col style={width ? style : {}} key={dataIndex} />;
+              })}
+            </colgroup>
+            <thead>
+              <tr class="file-table-tr">
+                {columnList.map(item => {
+                  const { title = '', align = 'center', dataIndex } = item;
+                  return (
+                    <th class={['file-table-th', align]} key={dataIndex}>
+                      {title}
+                    </th>
+                  );
+                })}
+              </tr>
+            </thead>
+            <tbody>
+              {dataSource.map((record = {}, index) => {
+                return (
+                  <tr class="file-table-tr" key={`${index + record.name || ''}`}>
+                    {columnList.map(item => {
+                      const { dataIndex = '', customRender, align = 'center' } = item;
+                      const render = customRender && isFunction(customRender);
+                      return (
+                        <td class={['file-table-td', align]} key={dataIndex}>
+                          {render
+                            ? customRender?.({ text: record[dataIndex], record })
+                            : record[dataIndex]}
+                        </td>
+                      );
+                    })}
+                  </tr>
+                );
+              })}
+            </tbody>
+          </table>
+        );
+      };
+    },
+  });
+</script>
+<style lang="less">
+  .file-table {
+    width: 100%;
+    border-collapse: collapse;
+
+    .center {
+      text-align: center;
+    }
+
+    .left {
+      text-align: left;
+    }
+
+    .right {
+      text-align: right;
+    }
+
+    &-th,
+    &-td {
+      padding: 12px 8px;
+    }
+
+    thead {
+      background-color: @background-color-light;
+    }
+
+    table,
+    td,
+    th {
+      border: 1px solid @border-color-base;
+    }
+  }
+</style>

+ 80 - 0
src/components/XTUpload/src/UploadPreviewModal.vue

@@ -0,0 +1,80 @@
+<template>
+  <BasicModal
+    :width="800"
+    :minHeight="600"
+    :title="getTitle"
+    class="upload-preview-modal"
+    v-bind="$attrs"
+    @register="register"
+    cancel-text="关闭"
+    ok-text="下载"
+    @ok="handleDownload"
+  >
+    <!-- <template #insertFooter>
+      <a-button type="primary" color="success" @click="handleDownload">下载</a-button>
+    </template> -->
+    <div class="upload-preview-modal__cnt">
+      <iframe
+        :id="fileInfo.id"
+        class="upload-preview-modal__iframe"
+        :src="fileInfo.previewUrl"
+        :title="fileInfo.realName"
+      />
+    </div>
+  </BasicModal>
+</template>
+<script lang="ts">
+  import { defineComponent, ref } from 'vue';
+  import { BasicModal, useModalInner } from '/@/components/Modal';
+  import { downloadByUrl } from '/@/utils/file/download';
+
+  export default defineComponent({
+    components: { BasicModal },
+    setup() {
+      const fileInfo = ref<any>({});
+      const getTitle = ref('');
+      const [register, { closeModal }] = useModalInner(async data => {
+        console.log(
+          '🚀 ~ file: UploadPreviewModal.vue:32 ~ const[register,{closeModal}]=useModalInner ~ data:',
+          data,
+        );
+        fileInfo.value = data;
+        getTitle.value = `预览-${data.realName}`;
+      });
+      // watch(
+      //   () => props.id,
+      //   value => {
+      //     console.log('🚀 ~ file: UploadPreviewModal.vue:37 ~ setup ~ value:', value);
+      //   },
+      //   { immediate: true },
+      // );
+
+      // 下载
+      function handleDownload() {
+        const url = fileInfo.value.absolutePath || '';
+        downloadByUrl({ url });
+      }
+
+      return {
+        fileInfo,
+        register,
+        closeModal,
+        handleDownload,
+        getTitle,
+      };
+    },
+  });
+</script>
+<style lang="less">
+  .upload-preview-modal {
+    // display: flex;
+    &__cnt {
+      height: 678px;
+    }
+
+    &__iframe {
+      width: 100%;
+      min-height: 678px;
+    }
+  }
+</style>

+ 354 - 0
src/components/XTUpload/src/XTUpload.vue

@@ -0,0 +1,354 @@
+<template>
+  <div>
+    <div class="flex upload-toolbar">
+      <Upload
+        :accept="getStringAccept"
+        :multiple="multiple"
+        :before-upload="beforeUpload"
+        :show-upload-list="false"
+        class="upload-toolbar__btn"
+      >
+        <a-button>
+          <upload-outlined />
+          上传文件
+        </a-button>
+      </Upload>
+      <Alert :message="getHelpText" type="info" banner class="upload-toolbar__text" />
+    </div>
+    <div class="upload-filelist">
+      <!-- <div>已上传</div> -->
+      <div class="upload-filelist__item" v-for="item in fileList" :key="item.id">
+        <div>
+          <paper-clip-outlined class="mr-1" />
+          <span class="mr-2">{{ item.realName }}</span>
+          <span class="color-muted">({{ item.size }}kb)</span>
+        </div>
+        <div class="flex">
+          <!-- <span class="mr-2">30%</span> -->
+          <!-- <a :href="item.absolutePath" target="_blank" class="mr-2">预览</a> -->
+          <span
+            class="upload-filelist--color pointer"
+            @click="handlePreview(item)"
+            :title="'预览-' + item.realName"
+            >预览</span
+          >
+          <span class="upload-filelist--color pointer" @click="handleRemove(item, 1)">删除</span>
+        </div>
+      </div>
+    </div>
+    <!-- <div v-if="fileListRef.length" @click="handleStartUpload">未上传</div> -->
+    <div class="upload-filelist">
+      <div class="upload-filelist__item" v-for="item in fileListRef" :key="item.uuid">
+        <div class="upload-filelist__item-info">
+          <paper-clip-outlined class="mr-1" />
+          <span class="mr-2">{{ item.name }}</span>
+          <span>({{ item.size }}kb)</span>
+        </div>
+        <div class="upload-filelist__item-progress">
+          <a-progress
+            :percent="item.percent"
+            size="small"
+            :status="setProgressSataus(item.status)"
+          />
+        </div>
+        <div class="flex">
+          <!-- <span class="mr-2">30%</span> -->
+          <!-- <a :href="item.absolutePath" target="_blank" class="mr-2">预览</a> -->
+          <span class="upload-filelist--error" v-if="item.status == 'error'">上传失败</span>
+          <span class="upload-filelist--color" @click="handleRemove(item, 0)">删除</span>
+        </div>
+      </div>
+    </div>
+    <!-- <FileList :dataSource="fileListRef" /> -->
+    <UploadPreviewModal @register="registerPreviewModal" />
+  </div>
+</template>
+<script lang="ts">
+  import { UploadOutlined, PaperClipOutlined } from '@ant-design/icons-vue';
+  import { PropType, defineComponent, reactive, ref, toRefs, unref, watch } from 'vue';
+  import { Upload, Alert } from 'ant-design-vue';
+  import { uploadContainerProps } from './props';
+  import { FileItem, UploadResultStatus } from './typing';
+  import { useModal } from '/@/components/Modal';
+  // hooks
+  import { useUploadType } from './useUpload';
+  import { useMessage } from '/@/hooks/web/useMessage';
+  // utils
+  import { isFunction, isArray } from '/@/utils/is';
+  import { warn } from '/@/utils/log';
+  // import FileList from './FileList.vue';
+  import { nanoid } from 'nanoid';
+  import locales from '/@/utils/locales';
+  import { getPreviewUrl, postFileRemoveByIds } from '/@/api/common';
+  import UploadPreviewModal from './UploadPreviewModal.vue';
+
+  export default defineComponent({
+    name: 'XTUpload',
+    components: {
+      UploadOutlined,
+      PaperClipOutlined,
+      Upload,
+      Alert,
+      UploadPreviewModal,
+      // FileList,
+    },
+    props: {
+      ...uploadContainerProps,
+      previewFileList: {
+        type: Array as PropType<string[]>,
+        default: () => [],
+      },
+    },
+    emits: ['change', 'delete'],
+    setup(props, { emit }) {
+      const state = reactive<{ fileList: FileItem[] }>({
+        fileList: [],
+      });
+      //   预览modal
+      const [registerPreviewModal, { openModal: openPreviewModal }] = useModal();
+
+      //   是否正在上传
+      const isUploadingRef = ref(false);
+      const fileList = ref<any[]>([]);
+      const fileListRef = ref<FileItem[]>([]);
+      const previewFile = ref<any>({});
+      const { accept, helpText, maxNumber, maxSize } = toRefs(props);
+
+      const { getStringAccept, getHelpText } = useUploadType({
+        acceptRef: accept,
+        helpTextRef: helpText,
+        maxNumberRef: maxNumber,
+        maxSizeRef: maxSize,
+      });
+
+      const { createMessage } = useMessage();
+
+      watch(
+        () => props.value,
+        (value = []) => {
+          fileList.value = isArray(value) ? value : [];
+          console.log('🚀 ~ file: XTUpload.vue:80 ~ setup ~ fileList.value :', fileList.value);
+        },
+        { immediate: true },
+      );
+
+      // 上传前校验
+      async function beforeUpload(file: File) {
+        const { size, name } = file;
+        const { maxSize } = props;
+        // 设置最大值,则判断
+        if (maxSize && file.size / 1024 / 1024 >= maxSize) {
+          createMessage.error(`只能上传不超过${maxSize}MB的文件!`);
+          return false;
+        }
+        const commonItem = {
+          uuid: nanoid(5),
+          file,
+          size,
+          name,
+          percent: 0,
+          type: name.split('.').pop(),
+        };
+        // 生成文件列表
+        fileListRef.value = [...unref(fileListRef), commonItem];
+        console.log(
+          '🚀 ~ file: XTUpload.vue:114 ~ beforeUpload ~ fileListRef.value:',
+          fileListRef.value,
+        );
+        await handleStartUpload();
+        return false;
+        // return false 会停止自动上传
+      }
+      // 删除
+      async function handleRemove(record, bool) {
+        if (bool) {
+          const index = fileList.value.findIndex(item => item.id === record.id);
+          index !== -1 && fileList.value.splice(index, 1);
+          emit('delete', record);
+          await postFileRemoveByIds([record.id]);
+        } else {
+          const index = fileListRef.value.findIndex(item => item.uuid === record.uuid);
+          index !== -1 && fileListRef.value.splice(index, 1);
+          emit('delete', record);
+        }
+      }
+      // 预览
+      async function handlePreview(record) {
+        // console.log('🚀 ~ file: XTUpload.vue:147 ~ handlePreview ~ record:', record);
+        const res = await getPreviewUrl(record.id);
+        console.log('预览数据', { ...record, previewUrl: res });
+        previewFile.value = {
+          ...record,
+          previewUrl: res,
+        };
+        openPreviewModal(true, {
+          ...record,
+          previewUrl: res,
+        });
+      }
+      // 进度状态渲染
+      function setProgressSataus(uploadStatus) {
+        let status: 'normal' | 'exception' | 'active' | 'success' = 'normal';
+        if (uploadStatus === UploadResultStatus.ERROR) {
+          status = 'exception';
+        } else if (uploadStatus === UploadResultStatus.UPLOADING) {
+          status = 'active';
+        } else if (uploadStatus === UploadResultStatus.SUCCESS) {
+          status = 'success';
+        }
+        return status;
+      }
+      async function uploadApiByItem(item: FileItem) {
+        const { api } = props;
+        if (!api || !isFunction(api)) {
+          return warn('upload api must exist and be a function');
+        }
+        try {
+          // eslint-disable-next-line no-unsafe-optional-chaining
+          const { data } = await props?.api?.(
+            {
+              data: {
+                ...(props.uploadParams || {}),
+              },
+              file: item.file,
+              name: props.name,
+              filename: props.filename,
+            },
+            function onUploadProgress(progressEvent: ProgressEvent) {
+              const complete = ((progressEvent.loaded / progressEvent.total) * 100) | 0;
+              item.percent = complete;
+            },
+          );
+          item.status = UploadResultStatus.SUCCESS;
+          item.responseData = data;
+          console.log(
+            '🚀 ~ file: XTUpload.vue:191 ~ handleStartUpload ~ fileListRef.value:',
+            fileListRef.value,
+          );
+          fileListRef.value.forEach(ele => {
+            const responseData = ele.responseData;
+            if (responseData.code == '00000') {
+              fileList.value.push(responseData.data);
+            }
+          });
+          fileListRef.value = fileListRef.value.filter(ele => {
+            return ele.status != UploadResultStatus.SUCCESS;
+          });
+          return {
+            success: true,
+            error: null,
+          };
+        } catch (e) {
+          console.log(e);
+          item.status = UploadResultStatus.ERROR;
+          return {
+            success: false,
+            error: e,
+          };
+        }
+      }
+      // 点击开始上传
+      async function handleStartUpload() {
+        const { maxNumber } = props;
+        if ((fileListRef.value.length + fileList.value.length ?? 0) > maxNumber) {
+          return createMessage.warning(locales.upload.maxNumber, maxNumber);
+        }
+        try {
+          isUploadingRef.value = true;
+          // 只上传不是成功状态的
+          const uploadFileList =
+            fileListRef.value.filter(item => item.status !== UploadResultStatus.SUCCESS) || [];
+          const data = await Promise.all(
+            uploadFileList.map(item => {
+              const result = uploadApiByItem(item);
+              return result;
+            }),
+          );
+          isUploadingRef.value = false;
+          // 生产环境:抛出错误
+          const errorList = data.filter((item: any) => !item.success);
+          if (errorList.length > 0) throw errorList;
+        } catch (e) {
+          isUploadingRef.value = false;
+          throw e;
+        }
+      }
+
+      return {
+        getHelpText,
+        getStringAccept,
+        beforeUpload,
+        fileListRef,
+        state,
+        isUploadingRef,
+        handleStartUpload,
+        locales,
+        fileList,
+        handleRemove,
+        handlePreview,
+        previewFile,
+        registerPreviewModal,
+        setProgressSataus,
+      };
+    },
+  });
+</script>
+
+<style lang="less" scoped>
+  .upload {
+    .ant-upload-list {
+      display: none;
+    }
+
+    .ant-table-wrapper .ant-spin-nested-loading {
+      padding: 0;
+    }
+
+    &-toolbar {
+      display: flex;
+      align-items: center;
+      margin-bottom: 8px;
+
+      &__btn {
+        text-align: left;
+        margin-right: 8px;
+      }
+
+      &__text {
+        height: 32px;
+        width: 100%;
+      }
+    }
+
+    &-filelist {
+      &__item {
+        display: flex;
+        justify-content: space-between;
+        background-color: rgb(241 247 255 / 100%);
+        margin-bottom: 4px;
+        padding: 5px 8px;
+
+        &-info {
+          max-width: 220px;
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+
+        &-progress {
+          min-width: 180px;
+        }
+      }
+
+      &--color {
+        color: rgb(0 117 255 / 100%);
+        margin-left: 16px;
+      }
+
+      &--error {
+        color: rgb(255 72 4 / 100%);
+        margin-left: 16px;
+      }
+    }
+  }
+</style>

+ 83 - 0
src/components/XTUpload/src/props.ts

@@ -0,0 +1,83 @@
+import type { PropType } from 'vue';
+import { FileBasicColumn } from './typing';
+
+export const basicProps = {
+  helpText: {
+    type: String as PropType<string>,
+    default: '',
+  },
+  // 文件最大多少MB
+  maxSize: {
+    type: Number as PropType<number>,
+    default: 2,
+  },
+  // 最大数量的文件,Infinity不限制
+  maxNumber: {
+    type: Number as PropType<number>,
+    default: Infinity,
+  },
+  // 根据后缀,或者其他
+  accept: {
+    type: Array as PropType<string[]>,
+    default: () => [],
+  },
+  multiple: {
+    type: Boolean as PropType<boolean>,
+    default: true,
+  },
+  uploadParams: {
+    type: Object as PropType<any>,
+    default: () => ({}),
+  },
+  api: {
+    type: Function as PropType<PromiseFn>,
+    default: null,
+    required: true,
+  },
+  name: {
+    type: String as PropType<string>,
+    default: 'file',
+  },
+  filename: {
+    type: String as PropType<string>,
+    default: null,
+  },
+};
+
+export const uploadContainerProps = {
+  value: {
+    type: Array as PropType<string[]>,
+    default: () => [],
+  },
+  ...basicProps,
+  showPreviewNumber: {
+    type: Boolean as PropType<boolean>,
+    default: true,
+  },
+  emptyHidePreview: {
+    type: Boolean as PropType<boolean>,
+    default: false,
+  },
+};
+
+export const previewProps = {
+  fileInfo: {
+    type: Object,
+    default: () => {},
+  },
+};
+
+export const fileListProps = {
+  columns: {
+    type: [Array] as PropType<FileBasicColumn[]>,
+    default: null,
+  },
+  actionColumn: {
+    type: Object as PropType<FileBasicColumn>,
+    default: null,
+  },
+  dataSource: {
+    type: Array as PropType<any[]>,
+    default: null,
+  },
+};

+ 56 - 0
src/components/XTUpload/src/typing.ts

@@ -0,0 +1,56 @@
+import { UploadApiResult } from '/@/api/sys/model/uploadModel';
+
+export enum UploadResultStatus {
+  SUCCESS = 'success',
+  ERROR = 'error',
+  UPLOADING = 'uploading',
+}
+
+export interface FileItem {
+  thumbUrl?: string;
+  name: string;
+  size: string | number;
+  type?: string;
+  percent: number;
+  file: File;
+  status?: UploadResultStatus;
+  responseData?: UploadApiResult;
+  uuid?: string;
+  id?: string;
+}
+
+export interface PreviewFileItem {
+  url: string;
+  name: string;
+  type: string;
+}
+
+export interface FileBasicColumn {
+  /**
+   * Renderer of the table cell. The return value should be a VNode, or an object for colSpan/rowSpan config
+   * @type Function | ScopedSlot
+   */
+  customRender?: Function;
+  /**
+   * Title of this column
+   * @type any (string | slot)
+   */
+  title: string;
+
+  /**
+   * Width of this column
+   * @type string | number
+   */
+  width?: number;
+  /**
+   * Display field of the data record, could be set like a.b.c
+   * @type string
+   */
+  dataIndex: string;
+  /**
+   * specify how content is aligned
+   * @default 'left'
+   * @type string
+   */
+  align?: 'left' | 'right' | 'center';
+}

+ 58 - 0
src/components/XTUpload/src/useUpload.ts

@@ -0,0 +1,58 @@
+import { Ref, unref, computed } from 'vue';
+export function useUploadType({
+  acceptRef,
+  helpTextRef,
+  maxNumberRef,
+  maxSizeRef,
+}: {
+  acceptRef: Ref<string[]>;
+  helpTextRef: Ref<string>;
+  maxNumberRef: Ref<number>;
+  maxSizeRef: Ref<number>;
+}) {
+  // 文件类型限制
+  const getAccept = computed(() => {
+    const accept = unref(acceptRef);
+    if (accept && accept.length > 0) {
+      return accept;
+    }
+    return [];
+  });
+  const getStringAccept = computed(() => {
+    return unref(getAccept)
+      .map(item => {
+        if (item.indexOf('/') > 0 || item.startsWith('.')) {
+          return item;
+        } else {
+          return `.${item}`;
+        }
+      })
+      .join(',');
+  });
+
+  // 支持jpg、jpeg、png格式,不超过2M,最多可选择10张图片,。
+  const getHelpText = computed(() => {
+    const helpText = unref(helpTextRef);
+    if (helpText) {
+      return helpText;
+    }
+    const helpTexts: string[] = [];
+
+    const accept = unref(acceptRef);
+    if (accept.length > 0) {
+      helpTexts.push(`只能上传${[accept.join(',')]}格式文件`);
+    }
+
+    const maxSize = unref(maxSizeRef);
+    if (maxSize) {
+      helpTexts.push(`单个文件不超过${maxSize}MB`);
+    }
+
+    const maxNumber = unref(maxNumberRef);
+    if (maxNumber && maxNumber !== Infinity) {
+      helpTexts.push(`最多只能上传${maxNumber}个文件`);
+    }
+    return helpTexts.join(',');
+  });
+  return { getAccept, getStringAccept, getHelpText };
+}

+ 3 - 0
src/design/index.less

@@ -65,6 +65,9 @@ span {
 .color-pink {
   color: pink;
 }
+.color-muted {
+  color: #828890;
+}
 
 .color-red {
   color: red;