Ver Fonte

fix:橙单升级

songzhen há 3 meses atrás
pai
commit
6006804f33

+ 7 - 0
components.d.ts

@@ -36,6 +36,7 @@ declare module 'vue' {
     ChartsRadarChartV3: typeof import('./src/components/Charts/radarChartV3.vue')['default']
     ChartsRichText: typeof import('./src/components/Charts/richText.vue')['default']
     ChartsScatterChart: typeof import('./src/components/Charts/scatterChart.vue')['default']
+    CustomUpload: typeof import('./src/components/CustomUpload/index.vue')['default']
     DateRange: typeof import('./src/components/DateRange/index.vue')['default']
     DeptSelect: typeof import('./src/components/DeptSelect/index.vue')['default']
     DeptSelectDeptSelectDlg: typeof import('./src/components/DeptSelect/DeptSelectDlg.vue')['default']
@@ -121,6 +122,11 @@ declare module 'vue' {
     MultiItemBox: typeof import('./src/components/MultiItemBox/index.vue')['default']
     MultiItemList: typeof import('./src/components/MultiItemList/index.vue')['default']
     PageCloseButton: typeof import('./src/components/PageCloseButton/index.vue')['default']
+    PreviewDialog: typeof import('./src/components/Preview/Dialog.vue')['default']
+    PreviewExcel: typeof import('./src/components/Preview/Excel.vue')['default']
+    PreviewPdf: typeof import('./src/components/Preview/Pdf.vue')['default']
+    PreviewVideo: typeof import('./src/components/Preview/Video.vue')['default']
+    PreviewWord: typeof import('./src/components/Preview/Word.vue')['default']
     Progress: typeof import('./src/components/Progress/index.vue')['default']
     RichEditor: typeof import('./src/components/RichEditor/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
@@ -131,6 +137,7 @@ declare module 'vue' {
     TableBox: typeof import('./src/components/TableBox/index.vue')['default']
     TableProgressColumn: typeof import('./src/components/TableProgressColumn/index.vue')['default']
     ThirdParty: typeof import('./src/components/thirdParty/index.vue')['default']
+    UploadFileList: typeof import('./src/components/UploadFileList/index.vue')['default']
     UserSelect: typeof import('./src/components/UserSelect/index.vue')['default']
     UserSelectUserSelectDlg: typeof import('./src/components/UserSelect/UserSelectDlg.vue')['default']
   }

+ 5 - 1
package.json

@@ -13,6 +13,9 @@
     "@element-plus/icons-vue": "^2.3.1",
     "@highlightjs/vue-plugin": "^2.1.0",
     "@layui/layui-vue": "^2.11.5",
+    "@vue-office/docx": "^1.6.2",
+    "@vue-office/excel": "^1.7.11",
+    "@vue-office/pdf": "^2.0.8",
     "@wangeditor/editor": "^5.1.23",
     "@wangeditor/editor-for-vue": "^5.1.12",
     "ace-builds": "^1.32.2",
@@ -36,6 +39,7 @@
     "print-js": "^1.6.0",
     "vant": "^4.7.3",
     "vue": "^3.3.8",
+    "vue-demi": "^0.14.6",
     "vue-draggable-plus": "0.3.1",
     "vue-json-viewer": "^3.0.4",
     "vue-router": "^4.2.5",
@@ -46,7 +50,7 @@
   "devDependencies": {
     "@types/ejs": "^3.1.5",
     "@types/json-bigint": "^1.0.4",
-    "@types/node": "^22.7.7",
+    "@types/node": "^18.11.17",
     "@typescript-eslint/eslint-plugin": "^5.46.1",
     "@typescript-eslint/parser": "^5.46.1",
     "@vant/auto-import-resolver": "^1.0.2",

+ 81 - 24
pnpm-lock.yaml

@@ -17,6 +17,15 @@ importers:
       '@layui/layui-vue':
         specifier: ^2.11.5
         version: 2.19.1(vue@3.5.12(typescript@4.9.5))
+      '@vue-office/docx':
+        specifier: ^1.6.2
+        version: 1.6.2(vue-demi@0.14.10(vue@3.5.12(typescript@4.9.5)))(vue@3.5.12(typescript@4.9.5))
+      '@vue-office/excel':
+        specifier: ^1.7.11
+        version: 1.7.11(vue-demi@0.14.10(vue@3.5.12(typescript@4.9.5)))(vue@3.5.12(typescript@4.9.5))
+      '@vue-office/pdf':
+        specifier: ^2.0.8
+        version: 2.0.9(vue-demi@0.14.10(vue@3.5.12(typescript@4.9.5)))(vue@3.5.12(typescript@4.9.5))
       '@wangeditor/editor':
         specifier: ^5.1.23
         version: 5.1.23
@@ -86,6 +95,9 @@ importers:
       vue:
         specifier: ^3.3.8
         version: 3.5.12(typescript@4.9.5)
+      vue-demi:
+        specifier: ^0.14.6
+        version: 0.14.10(vue@3.5.12(typescript@4.9.5))
       vue-draggable-plus:
         specifier: 0.3.1
         version: 0.3.1(@types/sortablejs@1.15.8)
@@ -112,8 +124,8 @@ importers:
         specifier: ^1.0.4
         version: 1.0.4
       '@types/node':
-        specifier: ^22.7.7
-        version: 22.8.6
+        specifier: ^18.11.17
+        version: 18.19.67
       '@typescript-eslint/eslint-plugin':
         specifier: ^5.46.1
         version: 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
@@ -1178,8 +1190,8 @@ packages:
   '@types/node-forge@1.3.11':
     resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==}
 
-  '@types/node@22.8.6':
-    resolution: {integrity: sha512-tosuJYKrIqjQIlVCM4PEGxOmyg3FCPa/fViuJChnGeEIhjA46oy8FMVoF9su1/v8PNs2a8Q0iFNyOx0uOF91nw==}
+  '@types/node@18.19.67':
+    resolution: {integrity: sha512-wI8uHusga+0ZugNp0Ol/3BqQfEcCCNfojtO6Oou9iVNGPTL6QNSdnUdqq85fRgIorLhLMuPIKpsN98QE9Nh+KQ==}
 
   '@types/normalize-package-data@2.4.4':
     resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@@ -1318,6 +1330,36 @@ packages:
     peerDependencies:
       vue: ^3.0.0
 
+  '@vue-office/docx@1.6.2':
+    resolution: {integrity: sha512-OHAoUHeY8nHjhWvwDhlPx+/rmRkxmqLpvPgtfCEOZ4H1c1LCdJ6eDbdV3152ww8dcdZ7fgGQu3fmSSaI7JwdpQ==}
+    peerDependencies:
+      '@vue/composition-api': ^1.7.1
+      vue: ^2.0.0 || >=3.0.0
+      vue-demi: ^0.14.6
+    peerDependenciesMeta:
+      '@vue/composition-api':
+        optional: true
+
+  '@vue-office/excel@1.7.11':
+    resolution: {integrity: sha512-LF3R9IV573Sf4qTu6Ik5Ee8UMfkrsZQ6HEQE25/2m1c0CMcHX6KanIy6Cz0b0Q+FrLH3TjIsLTm6oPcqAbDGSA==}
+    peerDependencies:
+      '@vue/composition-api': ^1.7.1
+      vue: ^2.0.0 || >=3.0.0
+      vue-demi: ^0.14.6
+    peerDependenciesMeta:
+      '@vue/composition-api':
+        optional: true
+
+  '@vue-office/pdf@2.0.9':
+    resolution: {integrity: sha512-sRjIiMV1CDDFv1Ls4U7zglkjkMr1Ntm16q8Krh0m1Lh7vAdVox1EcgEw4MXU6Mqsz7NB6nppxCPlSms6ue9eIQ==}
+    peerDependencies:
+      '@vue/composition-api': ^1.7.1
+      vue: ^2.0.0 || >=3.0.0
+      vue-demi: ^0.14.6
+    peerDependenciesMeta:
+      '@vue/composition-api':
+        optional: true
+
   '@vue/babel-helper-vue-jsx-merge-props@1.4.0':
     resolution: {integrity: sha512-JkqXfCkUDp4PIlFdDQ0TdXoIejMtTHP67/pvxlgeY+u5k3LEdKuWZ3LK6xkxo52uDoABIVyRwqVkfLQJhk7VBA==}
 
@@ -5375,8 +5417,8 @@ packages:
   unbox-primitive@1.0.2:
     resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
 
-  undici-types@6.19.8:
-    resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
+  undici-types@5.26.5:
+    resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
 
   unicode-canonical-property-names-ecmascript@2.0.1:
     resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
@@ -6857,20 +6899,20 @@ snapshots:
   '@types/body-parser@1.19.5':
     dependencies:
       '@types/connect': 3.4.38
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
 
   '@types/bonjour@3.5.13':
     dependencies:
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
 
   '@types/connect-history-api-fallback@1.5.4':
     dependencies:
       '@types/express-serve-static-core': 5.0.1
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
 
   '@types/connect@3.4.38':
     dependencies:
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
 
   '@types/ejs@3.1.5': {}
 
@@ -6885,14 +6927,14 @@ snapshots:
 
   '@types/express-serve-static-core@4.19.6':
     dependencies:
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
       '@types/qs': 6.9.16
       '@types/range-parser': 1.2.7
       '@types/send': 0.17.4
 
   '@types/express-serve-static-core@5.0.1':
     dependencies:
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
       '@types/qs': 6.9.16
       '@types/range-parser': 1.2.7
       '@types/send': 0.17.4
@@ -6910,7 +6952,7 @@ snapshots:
 
   '@types/http-proxy@1.17.15':
     dependencies:
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
 
   '@types/json-bigint@1.0.4': {}
 
@@ -6930,11 +6972,11 @@ snapshots:
 
   '@types/node-forge@1.3.11':
     dependencies:
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
 
-  '@types/node@22.8.6':
+  '@types/node@18.19.67':
     dependencies:
-      undici-types: 6.19.8
+      undici-types: 5.26.5
 
   '@types/normalize-package-data@2.4.4': {}
 
@@ -6942,7 +6984,7 @@ snapshots:
 
   '@types/qrcode@1.5.0':
     dependencies:
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
 
   '@types/qs@6.9.16': {}
 
@@ -6955,7 +6997,7 @@ snapshots:
   '@types/send@0.17.4':
     dependencies:
       '@types/mime': 1.3.5
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
 
   '@types/serve-index@1.9.4':
     dependencies:
@@ -6964,12 +7006,12 @@ snapshots:
   '@types/serve-static@1.15.7':
     dependencies:
       '@types/http-errors': 2.0.4
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
       '@types/send': 0.17.4
 
   '@types/sockjs@0.3.36':
     dependencies:
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
 
   '@types/sortablejs@1.15.8': {}
 
@@ -6979,7 +7021,7 @@ snapshots:
 
   '@types/ws@8.5.12':
     dependencies:
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
 
   '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
     dependencies:
@@ -7106,6 +7148,21 @@ snapshots:
     dependencies:
       vue: 3.5.12(typescript@4.9.5)
 
+  '@vue-office/docx@1.6.2(vue-demi@0.14.10(vue@3.5.12(typescript@4.9.5)))(vue@3.5.12(typescript@4.9.5))':
+    dependencies:
+      vue: 3.5.12(typescript@4.9.5)
+      vue-demi: 0.14.10(vue@3.5.12(typescript@4.9.5))
+
+  '@vue-office/excel@1.7.11(vue-demi@0.14.10(vue@3.5.12(typescript@4.9.5)))(vue@3.5.12(typescript@4.9.5))':
+    dependencies:
+      vue: 3.5.12(typescript@4.9.5)
+      vue-demi: 0.14.10(vue@3.5.12(typescript@4.9.5))
+
+  '@vue-office/pdf@2.0.9(vue-demi@0.14.10(vue@3.5.12(typescript@4.9.5)))(vue@3.5.12(typescript@4.9.5))':
+    dependencies:
+      vue: 3.5.12(typescript@4.9.5)
+      vue-demi: 0.14.10(vue@3.5.12(typescript@4.9.5))
+
   '@vue/babel-helper-vue-jsx-merge-props@1.4.0': {}
 
   '@vue/babel-helper-vue-transform-on@1.2.5': {}
@@ -9853,13 +9910,13 @@ snapshots:
 
   jest-worker@27.5.1:
     dependencies:
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
       merge-stream: 2.0.0
       supports-color: 8.1.1
 
   jest-worker@28.1.3:
     dependencies:
-      '@types/node': 22.8.6
+      '@types/node': 18.19.67
       merge-stream: 2.0.0
       supports-color: 8.1.1
 
@@ -11672,7 +11729,7 @@ snapshots:
       has-symbols: 1.0.3
       which-boxed-primitive: 1.0.2
 
-  undici-types@6.19.8: {}
+  undici-types@5.26.5: {}
 
   unicode-canonical-property-names-ecmascript@2.0.1: {}
 

BIN
src/assets/img/file-error.png


+ 318 - 0
src/components/Preview/Dialog.vue

@@ -0,0 +1,318 @@
+<template>
+  <el-dialog
+    width="80vw"
+    :modelValue="visible"
+    top="15vh"
+    :append-to-body="true"
+    class="dialog-preview"
+    :show-close="false"
+    :destroy-on-close="true"
+  >
+    <div class="preview-dialog">
+      <div class="preview-content">
+        <el-carousel
+          ref="carousel"
+          :initial-index="defaultIndex"
+          height="100%"
+          :autoplay="false"
+          style="display: flex; height: 100%"
+          :style="{ width: ready ? '100%' : '0px' }"
+          indicator-position="none"
+        >
+          <el-carousel-item v-for="(file, index) in getFileList" :key="file.url">
+            <div
+              style="
+                height: 100%;
+                display: flex;
+                justify-content: center;
+                align-items: center;
+                flex-direction: column;
+                padding: 0px 150px;
+              "
+            >
+              <div class="file-item">
+                <div class="file-header">
+                  <span>{{ file.name }}</span>
+                  <el-icon style="cursor: pointer" @click="closeDialog">
+                    <Close />
+                  </el-icon>
+                </div>
+                <div class="preview-box">
+                  <template v-if="file.error">
+                    <div
+                      class="error-box"
+                      style="
+                        display: flex;
+                        justify-content: center;
+                        align-items: center;
+                        flex-direction: column;
+                        height: 100%;
+                        width: 100%;
+                      "
+                    >
+                      <img :src="fileErrorImg" alt="error" />
+                      <span>加载失败</span>
+                    </div>
+                  </template>
+                  <template v-else>
+                    <div style="width: 100%; height: 100%">
+                      <el-image
+                        :src="file.url"
+                        style="width: 100%; height: 100%"
+                        fit="contain"
+                        v-if="file.type === EnumFileType.IMAGE"
+                      >
+                        <template #error>
+                          <div
+                            class="error-box"
+                            style="
+                              display: flex;
+                              justify-content: center;
+                              align-items: center;
+                              flex-direction: column;
+                              height: 100%;
+                              width: 100%;
+                            "
+                          >
+                            <img :src="fileErrorImg" alt="error" />
+                            <span>加载失败</span>
+                          </div>
+                        </template>
+                      </el-image>
+                      <template v-else-if="getComponent(file) != null">
+                        <component
+                          style="width: 100%; height: 100%"
+                          v-if="ready"
+                          :is="getComponent(file)"
+                          :file-url="file.url"
+                          :time="index === defaultIndex ? 10 : 10"
+                          @error="err => onPreviewError(file, err)"
+                        />
+                      </template>
+                      <div
+                        v-else
+                        class="error-box"
+                        style="
+                          display: flex;
+                          justify-content: center;
+                          align-items: center;
+                          flex-direction: column;
+                          height: 100%;
+                          width: 100%;
+                        "
+                      >
+                        <img :src="fileErrorImg" alt="error" />
+                        <span>暂不支持预览</span>
+                      </div>
+                    </div>
+                  </template>
+                </div>
+              </div>
+              <div class="menu-box" v-if="!readonly">
+                <!--
+                <el-button
+                  type="default"
+                  size="small"
+                  style="width: 80px"
+                  @click="onDeleteFile(file)"
+                >
+                  删除
+                </el-button>
+                -->
+                <slot />
+                <el-button
+                  type="primary"
+                  size="default"
+                  style="width: 80px"
+                  @click="onDownloadFile(file)"
+                >
+                  下载
+                </el-button>
+              </div>
+            </div>
+          </el-carousel-item>
+        </el-carousel>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { Plus, Close } from '@element-plus/icons-vue';
+import { defineProps, defineEmits, withDefaults } from 'vue';
+import PdfPreview from './Pdf.vue';
+import WordPreview from './Word.vue';
+import ExcelPreview from './Excel.vue';
+import VideoPreview from './Video.vue';
+import { EnumFileType } from '@/common/staticDict/index';
+import fileErrorImg from '@/assets/img/file-error.png';
+import { useDownload } from '@/common/hooks/useDownload';
+import { getFileType } from '@/common/utils';
+
+const props = withDefaults(
+  defineProps<{
+    visible: boolean;
+    fileList: Array<{ url: string; name: string; type: string }>;
+    defaultIndex: number;
+    readonly: boolean;
+  }>(),
+  {
+    visible: false,
+    fileList: () => [],
+    defaultIndex: 0,
+    readonly: false,
+  },
+);
+
+const emit = defineEmits(['close', 'delete', 'download']);
+const { downloadFile } = useDownload();
+const ready = ref(false);
+const getFileList = ref([]);
+
+const onPreviewError = (file, err) => {
+  file.error = true;
+  getFileList.value = [...getFileList.value];
+};
+
+const closeDialog = () => {
+  emit('close');
+};
+
+const getFileTypeByName = file => {
+  if (file && file.type) {
+    return file.type;
+  } else {
+    return getFileType(file.name);
+  }
+};
+
+const getComponent = file => {
+  switch (file.type) {
+    case EnumFileType.PDF:
+      return PdfPreview;
+    case EnumFileType.WORD:
+      return WordPreview;
+    case EnumFileType.EXCEL:
+      return ExcelPreview;
+    case EnumFileType.VIDEO:
+      return VideoPreview;
+    default:
+      return null;
+  }
+};
+
+const onDeleteFile = file => {
+  let temp = props.fileList.filter(item => item.url !== file.url);
+  this.$emit('delete', file, temp);
+};
+
+const onDownloadFile = file => {
+  downloadFile(file.url, file.name);
+  emit('download', file);
+};
+
+watch(
+  () => props.fileList,
+  val => {
+    getFileList.value = val.map(file => {
+      return {
+        ...file,
+        type: getFileTypeByName(file),
+        error: false,
+      };
+    });
+  },
+  { immediate: true },
+);
+
+onMounted(() => {
+  // 如果没有延迟element的走马灯组件会有布局错乱问题
+  setTimeout(() => {
+    ready.value = true;
+  }, 400);
+});
+</script>
+
+<style scoped>
+.preview-dialog {
+  width: 100%;
+  height: 70vh;
+  display: flex;
+  justify-content: space-around;
+  align-items: center;
+}
+
+.error-box img {
+  width: 159px;
+  height: 91px;
+  padding: 5px;
+  border: 1px dashed #d5d3d3;
+}
+.error-box span {
+  color: #3d3d3d;
+  font-size: 14px;
+  margin-top: 30px;
+}
+
+.preview-content {
+  width: 100%;
+  height: 100%;
+}
+
+.preview-dialog ::v-deep .el-carousel__container {
+  height: 100%;
+  width: 100%;
+}
+
+.file-item {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background: white;
+  border-radius: 6px;
+  box-shadow: 0px 1px 6px 0px rgba(0, 0, 0, 0.2);
+}
+
+.file-header {
+  display: flex;
+  flex-shrink: 0;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0 16px;
+  height: 44px;
+  font-weight: 500;
+  border-bottom: 1px solid #eeeff1;
+}
+.file-header span {
+  color: #3d3d3d;
+  font-size: 14px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-right: 10px;
+}
+.preview-box {
+  flex-grow: 1;
+  height: 100px;
+  padding: 10px;
+  overflow: hidden;
+}
+.menu-box {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  flex-shrink: 0;
+  margin-top: 20px;
+}
+</style>
+
+<style>
+.el-dialog.dialog-preview {
+  background: transparent;
+  box-shadow: none !important;
+}
+.el-dialog.dialog-preview .el-dialog__header {
+  display: none;
+}
+</style>

+ 30 - 0
src/components/Preview/Excel.vue

@@ -0,0 +1,30 @@
+<template>
+  <div style="width: 100%; height: 100%">
+    <vue-office-excel
+      style="width: 100%; height: 100%"
+      :src="fileUrl"
+      @error="errorHandler"
+      @rendered="renderedHandler"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { defineProps, defineEmits } from 'vue';
+import VueOfficeExcel from '@vue-office/excel';
+import '@vue-office/excel/lib/index.css';
+
+const props = defineProps<{
+  fileUrl: string;
+}>();
+const emit = defineEmits(['error', 'success']);
+
+const errorHandler = error => {
+  emit('error', error);
+};
+const renderedHandler = () => {
+  emit('success');
+};
+</script>
+
+<style></style>

+ 30 - 0
src/components/Preview/Pdf.vue

@@ -0,0 +1,30 @@
+<template>
+  <el-scrollbar style="height: 100%">
+    <vue-office-pdf v-if="ready" :src="fileUrl" @error="errorHandler" @rendered="renderedHandler" />
+  </el-scrollbar>
+</template>
+
+<script setup lang="ts">
+import { defineProps, defineEmits } from 'vue';
+import VueOfficePdf from '@vue-office/pdf';
+
+const props = defineProps<{
+  fileUrl: string;
+}>();
+const emit = defineEmits(['error', 'success']);
+
+const ready = ref(false);
+
+const errorHandler = error => {
+  emit('error', error);
+};
+const renderedHandler = () => {
+  emit('success');
+};
+
+onMounted(() => {
+  ready.value = true;
+});
+</script>
+
+<style></style>

+ 56 - 0
src/components/Preview/Video.vue

@@ -0,0 +1,56 @@
+<template>
+  <video ref="video" controls :poster="posterUrl">
+    <source :src="fileUrl" />
+    Your browser does not support the video tag.
+  </video>
+</template>
+
+<script setup lang="ts">
+import { defineProps, defineEmits, onMounted, onUnmounted } from 'vue';
+
+const props = defineProps<{
+  fileUrl: string;
+  posterUrl: string;
+}>();
+
+const emit = defineEmits(['error', 'play', 'pause', 'ended', 'loadedmetadata']);
+const video = ref<HTMLVideoElement | null>(null);
+
+const errorHandler = () => {
+  emit('error', ref);
+};
+
+const playHandler = () => {
+  emit('play', ref);
+};
+
+const pauseHandler = () => {
+  emit('pause', ref);
+};
+
+const endedHandler = () => {
+  emit('ended', ref);
+};
+
+const loadedmetadataHandler = () => {
+  emit('loadedmetadata', ref);
+};
+
+onMounted(() => {
+  video.value?.addEventListener('error', errorHandler);
+  video.value?.addEventListener('play', playHandler);
+  video.value?.addEventListener('pause', pauseHandler);
+  video.value?.addEventListener('ended', endedHandler);
+  video.value?.addEventListener('loadedmetadata', loadedmetadataHandler);
+});
+
+onUnmounted(() => {
+  video.value?.removeEventListener('error', errorHandler);
+  video.value?.removeEventListener('play', playHandler);
+  video.value?.removeEventListener('pause', pauseHandler);
+  video.value?.removeEventListener('ended', endedHandler);
+  video.value?.removeEventListener('loadedmetadata', loadedmetadataHandler);
+});
+</script>
+
+<style></style>

+ 30 - 0
src/components/Preview/Word.vue

@@ -0,0 +1,30 @@
+<template>
+  <div style="width: 100%; height: 100%">
+    <vue-office-docx
+      style="width: 100%; height: 100%"
+      :src="fileUrl"
+      @error="errorHandler"
+      @rendered="renderedHandler"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { defineProps, defineEmits } from 'vue';
+import VueOfficeDocx from '@vue-office/docx';
+import '@vue-office/docx/lib/index.css';
+
+const props = defineProps<{
+  fileUrl: string;
+}>();
+const emit = defineEmits(['error', 'success']);
+
+const errorHandler = error => {
+  emit('error', error);
+};
+const renderedHandler = () => {
+  emit('success');
+};
+</script>
+
+<style></style>

+ 242 - 0
src/components/UploadFileList/index.vue

@@ -0,0 +1,242 @@
+<template>
+  <div class="file-list" :style="getFlexDirection">
+    <el-tooltip
+      effect="dark"
+      :content="file.name"
+      placement="top"
+      :disabled="type === 'text'"
+      v-for="file in getFileList"
+      :key="file.url"
+    >
+      <div
+        class="file-item"
+        :class="type"
+        :style="{
+          width: type === 'text' && direction === 'vertical' ? '100%' : undefined,
+          border: type === 'card' && !readonly ? '1px dashed #999999' : 'none',
+        }"
+        @click.stop="onPreviewFile(file, fileList)"
+      >
+        <template v-if="type === 'card'">
+          <el-image
+            v-if="file.type === EnumFileType.IMAGE"
+            :src="file.url"
+            fit="cover"
+            style="width: 100%; height: 100%"
+          />
+          <div v-else class="card-item-text">
+            <el-icon style="font-size: 24px"><Document /></el-icon>
+          </div>
+          <div v-if="supportPreview" class="priview-btn">预览</div>
+          <el-icon v-if="!readonly" class="close-btn" @click.stop="onRemoveFile(file, fileList)">
+            <CircleCloseFilled />
+          </el-icon>
+        </template>
+        <template v-else>
+          <span
+            :style="{
+              'flex-grow': direction === 'vertical' ? 1 : 0,
+              width: direction === 'vertical' ? '100px' : undefined,
+              'margin-right': direction === 'vertical' ? undefined : '8px',
+            }"
+            >{{ file.name }}</span
+          >
+          <el-icon
+            v-if="!readonly"
+            class="close-btn"
+            style="margin-left: 6px"
+            @click.stop="onRemoveFile(file, fileList)"
+          >
+            <CircleCloseFilled />
+          </el-icon>
+        </template>
+      </div>
+    </el-tooltip>
+    <div class="file-item" :class="type" v-if="supportAdd && $slots.default" @click="onAddFile">
+      <slot />
+    </div>
+    <PreviewDialog
+      v-if="showPreview"
+      :visible="showPreview"
+      :file-list="fileList"
+      :defaultIndex="fileIndex"
+      :readonly="!supportDownload"
+      @close="showPreview = false"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Plus, CircleCloseFilled, Document } from '@element-plus/icons-vue';
+import { defineProps, withDefaults, defineEmits } from 'vue';
+import PreviewDialog from '@/components/Preview/Dialog';
+import { useDownload } from '@/common/hooks/useDownload';
+import { EnumFileType } from '@/common/staticDict/index';
+import { getFileType } from '@/common/utils';
+
+const { downloadFile } = useDownload();
+const props = withDefaults(
+  defineProps<{
+    fileList: Array<{ url: string; name: string; type?: string | number }>;
+    type: string;
+    direction: string;
+    supportPreview: boolean;
+    supportDownload: boolean;
+    readonly: boolean;
+    supportAdd: boolean;
+  }>(),
+  {
+    fileList: () => [],
+    type: 'text',
+    direction: 'vertical',
+    supportPreview: true,
+    supportDownload: true,
+    readonly: false,
+    supportAdd: true,
+  },
+);
+
+const emit = defineEmits(['remove', 'add', 'preview']);
+
+const getFileList = computed(() => {
+  return props.fileList.map(file => {
+    return {
+      ...file,
+      type: getFileTypeByName(file),
+      error: false,
+    };
+  });
+});
+
+const getFlexDirection = computed(() => {
+  return {
+    'flex-direction': props.direction === 'vertical' && props.type !== 'card' ? 'column' : 'row',
+  };
+});
+
+const showPreview = ref(false);
+const fileIndex = ref(0);
+
+const getFileTypeByName = file => {
+  if (file && file.type) {
+    return file.type;
+  } else {
+    return getFileType(file.name);
+  }
+};
+
+const onRemoveFile = (file, fileList) => {
+  const index = fileList.findIndex(item => item.url === file.url);
+  fileList.splice(index, 1);
+  emit('remove', file, fileList);
+};
+
+const onPreviewFile = (file, fileList) => {
+  if (!props.supportPreview) return;
+  if (props.supportPreview) {
+    fileIndex.value = fileList.findIndex(item => item.url === file.url);
+    nextTick(() => {
+      showPreview.value = true;
+      emit('preview', file, fileList);
+    });
+  } else if (props.supportDownload) {
+    downloadFile(file.url, file.name);
+  }
+};
+
+const onAddFile = () => {
+  emit('add');
+};
+</script>
+
+<style lang="scss" scoped>
+.file-list {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-start;
+  align-items: flex-start;
+  flex-wrap: wrap;
+}
+.file-item.card {
+  width: 65px;
+  height: 65px;
+  line-height: 65px;
+  border-radius: 4px;
+  color: #999999;
+  position: relative;
+  text-align: center;
+  border: 1px dashed #999999;
+  margin: 4px 8px 4px 0px;
+  cursor: pointer;
+}
+.file-item.card .file-name {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 22px;
+  line-height: 22px;
+  text-align: center;
+  color: #3d3d3d;
+  font-size: 12px;
+  padding: 0px 4px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: pointer;
+}
+.file-item.card .priview-btn {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 22px;
+  line-height: 22px;
+  text-align: center;
+  background: rgba(0, 0, 0, 0.5);
+  color: #fff;
+  font-size: 12px;
+  display: none;
+  cursor: pointer;
+}
+.file-item.card:hover .priview-btn {
+  display: block;
+}
+.file-item.card .close-btn {
+  position: absolute;
+  top: -5px;
+  right: -7px;
+  width: 16px;
+  height: 16px;
+  color: #222222;
+  font-size: 16px;
+  cursor: pointer;
+  display: none;
+}
+.file-item.card:hover .close-btn {
+  display: block;
+}
+
+.file-item.text {
+  display: flex;
+  align-items: center;
+  height: 36px;
+  line-height: 36px;
+  color: #3d3d3d;
+  width: 100%;
+}
+.file-item.text span {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: pointer;
+}
+.file-item.text .close-btn {
+  color: #d8d8d8;
+  cursor: pointer;
+}
+.file-item.text:hover,
+.file-item.text:hover .close-btn {
+  color: $color-primary;
+}
+</style>