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

feat: 添加文件预览组件及相关功能,支持多种文件类型的展示与下载

xieyong 1 неделя назад
Родитель
Сommit
bad2fb2f18

+ 17 - 0
src/api/file/index.js

@@ -0,0 +1,17 @@
+import request from '@/utils/request';
+
+/**
+ * 根据文件id集合查询文件信息
+ * @param ids 文件id,支持数组或逗号分隔的字符串
+ */
+export async function queryIds(ids) {
+  const idStr = (Array.isArray(ids) ? ids.join(',') : String(ids || '')).trim();
+  if (!idStr) {
+    return [];
+  }
+  const res = await request.post('/fm/file/queryIds', { ids: `'${idStr}'` });
+  if (res.data.code == 0) {
+    return res.data.data || [];
+  }
+  return Promise.reject(new Error(res.data.message));
+}

+ 463 - 0
src/components/FilePreview/index.vue

@@ -0,0 +1,463 @@
+<template>
+  <div class="file-preview">
+    <!-- 加载中 -->
+    <div v-if="loading" class="fp-status">
+      <i class="el-icon-loading" /> 附件加载中...
+    </div>
+    <!-- 加载失败 -->
+    <div v-else-if="errorMsg" class="fp-status">{{ errorMsg }}</div>
+    <!-- 无附件 -->
+    <div v-else-if="!fileList.length" class="fp-status">暂无附件</div>
+
+    <div v-else class="fp-body">
+      <!-- 多个附件时的文件切换列表 -->
+      <div v-if="fileList.length > 1" class="fp-list">
+        <div
+          v-for="(item, index) in fileList"
+          :key="item.key"
+          class="fp-list-item"
+          :class="{ 'is-active': index === currentIndex }"
+          :title="item.name"
+          @click="handleSelect(index)"
+        >
+          <i :class="item.icon" />
+          <span class="fp-list-name">{{ item.name }}</span>
+        </div>
+      </div>
+
+      <!-- 文件预览区 -->
+      <div class="fp-main">
+        <div class="fp-toolbar">
+          <i :class="currentFile.icon" />
+          <span class="fp-title" :title="currentFile.name">{{
+            currentFile.name
+          }}</span>
+          <el-button
+            v-if="currentFile.url"
+            type="text"
+            icon="el-icon-view"
+            @click="handleOpenWindow"
+            >新窗口打开</el-button
+          >
+          <el-button
+            type="text"
+            icon="el-icon-download"
+            :disabled="!currentFile.storePath"
+            @click="handleDownload"
+            >下载</el-button
+          >
+        </div>
+
+        <div class="fp-viewer">
+          <!-- 视频:原生播放器,铺满预览区 -->
+          <video
+            v-if="currentFile.category === 'video'"
+            class="fp-video"
+            :src="currentFile.url"
+            controls
+            preload="metadata"
+          ></video>
+
+          <!-- 图片:支持点击放大 -->
+          <el-image
+            v-else-if="currentFile.category === 'image'"
+            class="fp-media"
+            :src="currentFile.url"
+            :preview-src-list="previewList"
+            fit="contain"
+          >
+            <div slot="placeholder" class="fp-status">
+              <i class="el-icon-picture-outline" />
+            </div>
+            <div slot="error" class="fp-status">图片加载失败</div>
+          </el-image>
+
+          <!-- pdf / 文本:按 blob 渲染,不会触发浏览器下载 -->
+          <div v-else-if="isNativePreview" class="fp-doc">
+            <div v-if="blobLoading" class="fp-status">
+              <i class="el-icon-loading" /> 文件加载中...
+            </div>
+            <iframe
+              v-else
+              class="fp-iframe"
+              :src="blobUrl || currentFile.url"
+              frameborder="0"
+            ></iframe>
+          </div>
+
+          <!-- word / excel 等:kkFileView 转换后预览 -->
+          <iframe
+            v-else-if="currentFile.previewUrl"
+            class="fp-iframe"
+            :src="currentFile.previewUrl"
+            frameborder="0"
+          ></iframe>
+
+          <!-- 其它格式:仅支持下载 -->
+          <div v-else class="fp-status">
+            <i class="el-icon-document" />
+            <p>该格式暂不支持在线预览,请下载后查看</p>
+            <el-button type="primary" size="small" @click="handleDownload"
+              >下载文件</el-button
+            >
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+  import { queryIds } from '@/api/file/index.js';
+  import { getFile } from '@/api/system/file/index.js';
+  import {
+    getFileCategory,
+    getFileUrl,
+    getKkFileUrl,
+    getFileApiUrls
+  } from '@/utils/file';
+  import request from '@/utils/request';
+
+  // 各类文件的图标
+  const ICON_MAP = {
+    video: 'el-icon-video-camera',
+    image: 'el-icon-picture-outline',
+    pdf: 'el-icon-document',
+    text: 'el-icon-tickets',
+    office: 'el-icon-notebook-2',
+    other: 'el-icon-tickets'
+  };
+
+  // 浏览器可直接展示的类型(pdf、文本),按 blob 渲染
+  const isNativeCategory = (category) =>
+    category === 'pdf' || category === 'text';
+
+  export default {
+    name: 'FilePreview',
+    props: {
+      // 文件id集合(数字库附件id)
+      ids: {
+        type: [Array, String],
+        default: () => []
+      }
+    },
+    data() {
+      return {
+        loading: false,
+        errorMsg: '',
+        fileList: [],
+        currentIndex: 0,
+        // 文档 blob 地址
+        blobUrl: '',
+        blobLoading: false,
+        // storePath -> blob 地址缓存
+        blobCache: {}
+      };
+    },
+    computed: {
+      // 当前展示的文件
+      currentFile() {
+        return this.fileList[this.currentIndex] || {};
+      },
+      // 图片预览列表
+      previewList() {
+        return this.fileList
+          .filter((item) => item.category === 'image')
+          .map((item) => item.url);
+      },
+      // 浏览器可直接展示的类型(pdf、文本)按 blob 渲染
+      isNativePreview() {
+        return isNativeCategory(this.currentFile.category);
+      }
+    },
+    watch: {
+      ids: {
+        immediate: true,
+        handler() {
+          this.load();
+        }
+      }
+    },
+    beforeDestroy() {
+      this.revokeBlob();
+    },
+    methods: {
+      // 加载附件信息
+      async load() {
+        const ids = this.normalizeIds(this.ids);
+        this.currentIndex = 0;
+        this.fileList = [];
+        this.errorMsg = '';
+        this.blobUrl = '';
+        if (!ids.length) {
+          return;
+        }
+        this.loading = true;
+        try {
+          const list = await queryIds(ids);
+          this.fileList = (list || []).map((item, index) =>
+            this.mapFile(item, index)
+          );
+          this.loadBlob(this.currentFile);
+        } catch (error) {
+          this.errorMsg = error.message || '附件加载失败';
+        } finally {
+          this.loading = false;
+        }
+      },
+      // 附件id标准化,兼容数组和逗号分隔字符串
+      normalizeIds(ids) {
+        const list = Array.isArray(ids) ? ids : String(ids || '').split(',');
+        return list
+          .map((item) => String(item || '').trim())
+          .filter((item) => item);
+      },
+      // 文件信息转换
+      mapFile(item, index) {
+        const storage = (item.storagePath && item.storagePath[0]) || {};
+        const fileName = storage.name || item.name || '';
+        const category = getFileCategory(fileName);
+        return {
+          key: item.id || `${index}-${fileName}`,
+          id: item.id,
+          name: fileName,
+          storage,
+          storePath: storage.storePath || '',
+          category,
+          icon: ICON_MAP[category] || ICON_MAP.other,
+          url: getFileUrl(storage),
+          previewUrl:
+            category === 'office' ? getKkFileUrl(storage, fileName) : ''
+        };
+      },
+      // 切换文件
+      handleSelect(index) {
+        this.currentIndex = index;
+        this.loadBlob(this.currentFile);
+      },
+      // pdf / 文本按 blob 渲染:带 token 鉴权,且浏览器不会直接下载
+      async loadBlob(file) {
+        this.blobUrl = '';
+        if (!file || !isNativeCategory(file.category)) {
+          return;
+        }
+        const cacheKey = file.storePath || file.url;
+        if (!cacheKey) {
+          return;
+        }
+        const cached = this.blobCache[cacheKey];
+        if (cached) {
+          this.blobUrl = cached;
+          return;
+        }
+        this.blobLoading = true;
+        try {
+          const url = await this.fetchBlob(file.storage);
+          if (url) {
+            this.blobCache[cacheKey] = url;
+            this.blobUrl = url;
+          }
+        } finally {
+          this.blobLoading = false;
+        }
+      },
+      // 依次尝试候选地址拉取文件二进制
+      async fetchBlob(storage) {
+        const urls = getFileApiUrls(storage);
+        for (const url of urls) {
+          try {
+            const res = await request.get(url, {
+              responseType: 'blob'
+            });
+            if (res.data && res.data.size) {
+              return URL.createObjectURL(res.data);
+            }
+          } catch (error) {
+            // 当前地址不可用,继续尝试下一个
+          }
+        }
+        return '';
+      },
+      // 释放 blob 地址
+      revokeBlob() {
+        Object.keys(this.blobCache).forEach((key) => {
+          URL.revokeObjectURL(this.blobCache[key]);
+        });
+        this.blobCache = {};
+        this.blobUrl = '';
+      },
+      // 新窗口打开文件
+      handleOpenWindow() {
+        const file = this.currentFile;
+        if (!file) {
+          this.$message.warning('文件地址不存在');
+          return;
+        }
+        // pdf / 文本优先用 blob 地址,避免新窗口直接触发下载
+        // word / excel 等走 kkFileView 转换预览
+        const url =
+          (isNativeCategory(file.category) && this.blobUrl) ||
+          file.previewUrl ||
+          file.url;
+        if (!url) {
+          this.$message.warning('文件地址不存在');
+          return;
+        }
+        window.open(url, '_blank');
+      },
+      // 使用 kkFile 预览(word、excel 等需要转换的格式)
+      handleKkPreview() {
+        const file = this.currentFile;
+        if (!file || !file.previewUrl) {
+          this.$message.warning('预览地址不存在');
+          return;
+        }
+        window.open(file.previewUrl, '_blank');
+      },
+      // 下载文件
+      handleDownload() {
+        const file = this.currentFile;
+        if (!file) {
+          this.$message.warning('文件地址不存在,无法下载');
+          return;
+        }
+        if (file.storePath) {
+          getFile({ objectName: file.storePath }, file.name);
+          return;
+        }
+        if (file.url) {
+          window.open(file.url, '_blank');
+          return;
+        }
+        this.$message.warning('文件地址不存在,无法下载');
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .file-preview {
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+
+    .fp-status {
+      width: 100%;
+      height: 100%;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      gap: 10px;
+      color: #909399;
+      font-size: 13px;
+
+      i {
+        font-size: 26px;
+      }
+    }
+
+    .fp-body {
+      width: 100%;
+      height: 100%;
+      display: flex;
+    }
+
+    .fp-list {
+      width: 200px;
+      flex-shrink: 0;
+      border-right: 1px solid #ebeef5;
+      background-color: #fff;
+      overflow-y: auto;
+      padding: 6px 0;
+
+      .fp-list-item {
+        display: flex;
+        align-items: center;
+        padding: 8px 12px;
+        font-size: 13px;
+        color: #606266;
+        cursor: pointer;
+
+        &:hover {
+          background-color: #f5f7fa;
+        }
+
+        &.is-active {
+          background-color: #ecf5ff;
+          color: #409eff;
+        }
+
+        .fp-list-name {
+          margin-left: 6px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+      }
+    }
+
+    .fp-main {
+      flex: 1;
+      min-width: 0;
+      display: flex;
+      flex-direction: column;
+
+      .fp-toolbar {
+        display: flex;
+        align-items: center;
+        padding: 6px 10px;
+        font-size: 13px;
+        color: #606266;
+        border-bottom: 1px solid #ebeef5;
+        background-color: #fff;
+
+        .fp-title {
+          flex: 1;
+          margin-left: 6px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+      }
+
+      .fp-viewer {
+        flex: 1;
+        min-height: 0;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        background-color: #eef5f9;
+        overflow: hidden;
+      }
+
+      // 图片:完整展示,不裁剪
+      .fp-media {
+        max-width: 100%;
+        max-height: 100%;
+      }
+
+      // 视频:铺满预览区
+      .fp-video {
+        width: 100%;
+        height: 100%;
+        object-fit: cover;
+      }
+
+      // 文档:撑满预览区
+      .fp-doc {
+        width: 100%;
+        height: 100%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+      }
+
+      .fp-iframe {
+        width: 100%;
+        height: 100%;
+        border: none;
+      }
+    }
+  }
+</style>

+ 201 - 3
src/utils/file.js

@@ -1,5 +1,5 @@
 import { getToken } from './token-util';
-import { TOKEN_HEADER_NAME } from '@/config/setting';
+import { TOKEN_HEADER_NAME, KKFILE_URL, API_BASE_URL } from '@/config/setting';
 
 // 获取图片反显url
 export function getImageUrl(path) {
@@ -12,6 +12,204 @@ export function getImageUrl(path) {
   //   }${path}`;
   // }
 }
+// 支持浏览器直接播放的视频格式
+export const VIDEO_EXTS = [
+  'mp4',
+  'webm',
+  'ogg',
+  'mov',
+  'avi',
+  'wmv',
+  'flv',
+  'mkv',
+  'm4v',
+  '3gp'
+];
+// 支持直接反显的图片格式
+export const IMAGE_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
+// 支持浏览器直接预览的 pdf
+export const PDF_EXTS = ['pdf'];
+// 纯文本,浏览器可直接展示
+export const TEXT_EXTS = ['txt', 'csv', 'md', 'json', 'xml', 'log'];
+// 需要 kkFileView 转换后才能预览的文档
+export const OFFICE_EXTS = [
+  'doc',
+  'docx',
+  'xls',
+  'xlsx',
+  'ppt',
+  'pptx',
+  'wps',
+  'et',
+  'dps',
+  'rtf',
+  'odt',
+  'ods',
+  'odp'
+];
+
+/**
+ * 获取文件扩展名(小写,不含点)
+ * @param name 文件名
+ */
+export function getFileExt(name = '') {
+  const str = String(name || '');
+  const index = str.lastIndexOf('.');
+  return index > -1 ? str.slice(index + 1).toLowerCase() : '';
+}
+
+/**
+ * 根据文件名判断文件的展示类型
+ * video-视频 / image-图片 / pdf-pdf / text-文本 / office-需转换的文档 / other-其它
+ * @param name 文件名
+ */
+export function getFileCategory(name = '') {
+  const ext = getFileExt(name);
+  if (VIDEO_EXTS.includes(ext)) {
+    return 'video';
+  }
+  if (IMAGE_EXTS.includes(ext)) {
+    return 'image';
+  }
+  if (PDF_EXTS.includes(ext)) {
+    return 'pdf';
+  }
+  if (TEXT_EXTS.includes(ext)) {
+    return 'text';
+  }
+  if (OFFICE_EXTS.includes(ext)) {
+    return 'office';
+  }
+  return 'other';
+}
+
+// 编码 objectName(保留路径分隔符,避免部分服务端对 %2F 敏感)
+function encodeObjectName(path) {
+  return encodeURIComponent(path).replace(/%2F/gi, '/');
+}
+
+/**
+ * 获取文件的绝对访问地址
+ * 优先使用 storagePath[].url(绝对地址),没有时再用 storePath 拼接口地址
+ * @param storage storagePath 中的文件信息
+ */
+export function getFileUrl(storage = {}) {
+  const info = typeof storage === 'string' ? { storePath: storage } : storage;
+  const url = String(info.url || '').trim();
+  if (url) {
+    // 已经是绝对地址,直接使用
+    if (/^(https?:)?\/\//i.test(url) || url.startsWith('data:')) {
+      return url;
+    }
+    // 相对路径按接口地址拼接
+    return getFileApiUrl() + '?objectName=' + encodeObjectName(url);
+  }
+  const storePath = String(info.storePath || '').trim();
+  if (!storePath) {
+    return '';
+  }
+  return getFileApiUrl() + '?objectName=' + encodeObjectName(storePath);
+}
+
+/**
+ * 生成 kkFile 在线预览地址(word、excel 等需要转换的文档)
+ * @param storage storagePath 中的文件信息
+ * @param fileName 文件名
+ */
+export function getKkFileUrl(storage = {}, fileName) {
+  const info = typeof storage === 'string' ? { storePath: storage } : storage;
+  const fetchUrl = getKkFileFetchUrl(info, fileName);
+  if (!fetchUrl) {
+    return '';
+  }
+  return KKFILE_URL + '/onlinePreview?url=' + toBase64(fetchUrl);
+}
+
+/**
+ * kkFileView 服务端拉取文件的地址
+ * @param storage storagePath 中的文件信息
+ * @param fileName 文件名
+ */
+function getKkFileFetchUrl(storage = {}, fileName) {
+  const url = String(storage.url || '').trim();
+  if (url && /^(https?:)?\/\//i.test(url)) {
+    // 绝对地址直接给 kkFile 服务端拉取
+    return appendFullName(url, fileName);
+  }
+  const storePath = String(storage.storePath || url || '').trim();
+  if (!storePath) {
+    return '';
+  }
+  const base = String(API_BASE_URL || '/api').replace(/\/$/, '');
+  let fetchUrl;
+  if (/^https?:\/\//i.test(base)) {
+    // 接口地址本身是绝对地址
+    fetchUrl = base + '/main/file/getFile';
+  } else {
+    const origin = window.location.origin.replace(/\/$/, '');
+    fetchUrl = origin + base + '/main/file/getFile';
+  }
+  fetchUrl += '?objectName=' + encodeObjectName(storePath);
+  return appendFullName(fetchUrl, fileName);
+}
+
+// 追加 fullfilename 参数(kkFileView 靠它识别文件类型)
+function appendFullName(url, fileName) {
+  if (!fileName) {
+    return url;
+  }
+  const separator = url.includes('?') ? '&' : '?';
+  return url + separator + 'fullfilename=' + encodeURIComponent(fileName);
+}
+
+/**
+ * 获取文件下载接口地址(不含查询参数)
+ */
+export function getFileApiUrl() {
+  const base = String(API_BASE_URL || '').replace(/\/$/, '');
+  if (!base) {
+    // 兜底:使用运行时配置的 filePath
+    const filePath = sessionStorage.filePath;
+    if (filePath) {
+      return filePath.split('?')[0];
+    }
+    return window.location.origin + '/main/file/getFile';
+  }
+  if (/^https?:\/\//i.test(base)) {
+    return base + '/main/file/getFile';
+  }
+  return window.location.origin + base + '/main/file/getFile';
+}
+
+/**
+ * 获取文件下载的候选地址(按优先级,完整地址)
+ * 用于通过 axios 拉取文件二进制,携带 token 鉴权
+ * @param storage storagePath 中的文件信息
+ */
+export function getFileApiUrls(storage = {}) {
+  const urls = [];
+  const url = String(storage.url || '').trim();
+  // 绝对地址优先
+  if (url && /^(https?:)?\/\//i.test(url)) {
+    urls.push(url);
+  }
+  // 同源接口地址兜底(避免跨域)
+  const storePath = String(storage.storePath || '').trim();
+  if (storePath) {
+    urls.push(getFileApiUrl() + '?objectName=' + encodeObjectName(storePath));
+  }
+  return urls;
+}
+
+// base64 编码,兼容中文等非 latin1 字符
+function toBase64(str) {
+  return btoa(
+    encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_m, p1) =>
+      String.fromCharCode(parseInt(p1, 16))
+    )
+  );
+}
+
 // 从反显url上获取接口需要path
 export function getImagePath(url) {
   if (!url) {
@@ -25,7 +223,7 @@ export function getImagePath(url) {
 
 // 下载方法
 
-export function download (data, name) {
+export function download(data, name) {
   const a = document.createElement('a');
   const url = window.URL.createObjectURL(data);
   const filename = name;
@@ -36,7 +234,7 @@ export function download (data, name) {
 }
 // 下载方法
 
-export function download1 (url, name) {
+export function download1(url, name) {
   const a = document.createElement('a');
   const filename = name;
   a.href = url;

+ 74 - 19
src/views/safetyTraining/components/learningPreview.vue

@@ -37,14 +37,8 @@
       <div class="right-content">
         <div class="content-title">{{ currentTitle }}</div>
         <div class="iframe-wrapper">
-          <div v-if="!showOverlay" class="iframe-placeholder">
-            <fileMain
-              v-if="currentAttachment.length"
-              v-model="currentAttachment"
-              type="view"
-            />
-            <span v-else>暂无附件</span>
-          </div>
+          <!-- 附件预览:按 视频 / 图片 / 文档 分别渲染 -->
+          <file-preview :ids="currentFileIds" class="preview-files" />
 
           <!-- 开始学习遮罩 -->
           <div v-if="showOverlay" class="start-overlay">
@@ -63,24 +57,39 @@
 </template>
 
 <script>
+  import FilePreview from '@/components/FilePreview/index.vue';
+  import { learn } from '@/api/onlineTrainingPlan/index.js';
+
   export default {
+    components: {
+      FilePreview
+    },
     data() {
       return {
         visible: false,
         treeTitle: '',
         currentTitle: '',
-        currentAttachment: [],
+        // 当前选中培训资源的附件id集合
+        currentFileIds: [],
         showOverlay: true,
         defaultProps: {
           children: 'children',
           label: 'label'
         },
-        treeData: []
+        treeData: [],
+        // 当前学习的任务ID
+        currentTaskId: null,
+        // 每分钟上报学习进度的定时器
+        learnTimer: null
       };
     },
+    beforeDestroy() {
+      this.stopLearnTimer();
+    },
     methods: {
-      open(row) {
+      open(row, needReport = false) {
         this.visible = true;
+        this.currentTaskId = row && row.id ? row.id : null;
         const resources = (row && row.trainingResourceList) || [];
         const materialName = (row && row.materialName) || '培训资料';
         const children = resources.map((item, index) => ({
@@ -89,16 +98,12 @@
           duration: item.suggestDuration
             ? `总时长: ${item.suggestDuration} 分钟`
             : '',
-          attachment: item.attachment
-            ? String(item.attachment)
-                .split(',')
-                .filter((id) => id)
-            : [],
+          attachment: this.parseAttachment(item.attachment),
           isLeaf: true
         }));
         this.treeTitle = materialName;
         this.currentTitle = children.length ? children[0].label : materialName;
-        this.currentAttachment = children.length ? children[0].attachment : [];
+        this.currentFileIds = children.length ? children[0].attachment : [];
         this.treeData = [
           {
             id: row.id || 'material',
@@ -107,20 +112,65 @@
           }
         ];
         this.showOverlay = true;
+        // 仅在「开始学习」调用时开启定时上报
+        if (needReport) {
+          this.startLearnTimer();
+        }
         this.$nextTick(() => {
           if (children.length) {
             this.$refs.treeRef.setCurrentKey(children[0].id);
           }
         });
       },
+      // 每分钟上报一次学习进度
+      startLearnTimer() {
+        this.stopLearnTimer();
+        if (!this.currentTaskId) {
+          return;
+        }
+        this.learnTimer = setInterval(() => {
+          this.reportLearn();
+        }, 60 * 1000);
+      },
+      stopLearnTimer() {
+        if (this.learnTimer) {
+          clearInterval(this.learnTimer);
+          this.learnTimer = null;
+        }
+      },
+      // 上报当前学习进度,无需传入learnedTime
+      async reportLearn() {
+        if (!this.currentTaskId) {
+          return;
+        }
+        try {
+          await learn({ taskId: this.currentTaskId });
+        } catch (error) {
+          this.$message.error(error.message || '学习进度上报失败');
+        }
+      },
       cancel() {
+        this.stopLearnTimer();
         this.visible = false;
         this.showOverlay = true;
-        this.currentAttachment = [];
+        this.currentFileIds = [];
+        this.currentTaskId = null;
+      },
+      // 附件id标准化,兼容字符串和数组
+      parseAttachment(attachment) {
+        if (!attachment) {
+          return [];
+        }
+        const list = Array.isArray(attachment)
+          ? attachment
+          : String(attachment).split(',');
+        return list
+          .map((item) => String(item || '').trim())
+          .filter((item) => item);
       },
       handleNodeClick(data) {
         this.currentTitle = data.label;
-        this.currentAttachment = data.attachment || [];
+        this.currentFileIds = data.attachment || [];
         this.showOverlay = true;
       },
       handleStartLearn() {
@@ -199,6 +249,11 @@
       overflow: hidden;
     }
 
+    .preview-files {
+      width: 100%;
+      height: 100%;
+    }
+
     .preview-iframe {
       width: 100%;
       height: 100%;

+ 22 - 2
vue.config.js

@@ -32,19 +32,39 @@ module.exports = {
       // 允许跨域访问子应用页面
       'Access-Control-Allow-Origin': '*'
     },
+    // 前端路由为 history 模式,接口与预览服务不能回退到 index.html
+    historyApiFallback: {
+      index: path.posix.join(publicPath, 'index.html'),
+      htmlAcceptHeaders: ['text/html'],
+      rewrites: [
+        {
+          from: /^\/(api|kkfile)(\/|$)/,
+          to: (context) => context.parsedUrl.pathname
+        }
+      ]
+    },
     // 代理跨域的配置
     proxy: {
       // 当我们的本地的请求 有/api的时候,就会代理我们的请求地址向另外一个服务器发出请求
       '/api': {
-        target: 'http://192.168.1.251:18086', //开发
+        // target: 'http://192.168.1.251:18086', //开发
         // target: 'http://192.168.1.251:18186', //测试
         // target: 'http://192.168.1.23:18086',//罗
         // target: 'http://192.168.1.144:18086',//付
-
+        // target: 'http://192.168.1.102:18086',
+        target: 'http://aiot.zoomwin.com.cn:51001/api',
         changeOrigin: true, // 只有这个值为true的情况下 才表示开启跨域
         pathRewrite: {
           '^/api': ''
         }
+      },
+      // kkFileView 在线预览服务(word、excel 等文档需要转换后才能预览)
+      '/kkfile': {
+        target: 'http://aiot.zoomwin.com.cn:51001',
+        changeOrigin: true,
+        pathRewrite: {
+          '^/kkfile': '/kkfile'
+        }
       }
     }
   },