OnlyOfficeViewer.vue 7.81 KB
<template>
  <div class="onlyoffice-viewer">
    <!-- 加载状态 -->
    <div v-if="loading" class="loading-state">
      <i class="fas fa-spinner fa-spin"></i>
      <span>{{ loadingText }}</span>
    </div>

    <!-- 错误状态 -->
    <div v-else-if="error" class="error-state">
      <i class="fas fa-exclamation-triangle"></i>
      <p class="error-text">{{ error }}</p>
      <el-button @click="initViewer" size="small">重试</el-button>
    </div>

    <!-- 编辑器容器 -->
    <div :id="'onlyoffice-editor-' + props.fileId" class="editor-container"></div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, watch, onBeforeUnmount } from "vue";
import { ElButton } from "element-plus";
import * as filesApi from "@/api/files";
import { apiGetDraftById } from "@/api/drafts";
import { openDocument } from "@/utils/onlyoffice/converter";
import { fileCache } from "@/utils/fileCache";
import { fetchOssBlob } from "@/utils/tosUpload";

interface Props {
  fileId: string | number;
  fileName: string;
  isChatAnswer?: boolean;
}

const props = defineProps<Props>();

const loading = ref(true);
const error = ref<string | null>(null);

const loadingText = ref("正在准备文档...");
let isInitializing = false;
let editorInstance: any = null;

const LOG = (msg: string, ...args: any[]) => console.log(`[OnlyOffice:${props.fileId}] ${msg}`, ...args);
const LOG_ERR = (msg: string, ...args: any[]) => console.error(`[OnlyOffice:${props.fileId}] ${msg}`, ...args);

// 初始化预览器
const initViewer = async () => {
  if (isInitializing) return;
  isInitializing = true;

  loading.value = true;
  error.value = null;
  loadingText.value = "正在获取文档...";
  LOG(`initViewer start: fileName=${props.fileName} isChatAnswer=${props.isChatAnswer}`);

  try {
    let blob: Blob | null = null;
    let openFileName = props.fileName;
    const fileId = props.fileId;
    const fileExt = props.fileName.split('.').pop()?.toLowerCase() || '';

    const toExtMap: Record<string, string> = {
      doc: 'docx',
      ppt: 'pptx',
      xls: 'xlsx',
    };
    const toExt = toExtMap[fileExt];
    LOG(`ext=${fileExt} toExt=${toExt} isChatAnswer=${props.isChatAnswer}`);

    // 1. 旧版格式(doc/ppt/xls):仅从 TOS 加载后端转换版本,未命中则直接提示重新上传
    //    新版格式(docx/pptx/xlsx)跳过此步骤,走缓存/原始下载
    if (toExt && !props.isChatAnswer) {
      const convertedKey = `docs-parsed/prod${fileId}/convert.${toExt}`;
      const convertedFileName = props.fileName.replace(/\.[^.]+$/, `.${toExt}`);
      LOG(`trying TOS converted: key=${convertedKey}`);
      loadingText.value = "正在获取文档预览版本...";
      try {
        const convertedBlob = await fetchOssBlob({ storageKey: convertedKey, fileName: convertedFileName });
        if (convertedBlob && convertedBlob.size > 0) {
          LOG(`TOS hit: size=${convertedBlob.size}`);
          blob = convertedBlob;
          openFileName = convertedFileName;
        } else {
          LOG(`TOS returned empty blob`);
          throw new Error(`该文件为旧版格式(.${fileExt.toUpperCase()}),暂不支持直接预览,请重新上传文件后再试`);
        }
      } catch (e: any) {
        if (e.message?.includes('暂不支持直接预览')) throw e;
        LOG(`TOS miss (${e?.message})`);
        throw new Error(`该文件为旧版格式(.${fileExt.toUpperCase()}),暂不支持直接预览,请重新上传文件后再试`);
      }
    }

    // 2. TOS 未命中时,尝试缓存
    if (!blob) {
      try {
        const cached = await fileCache.getFile(fileId);
        if (cached && cached.content instanceof Blob) {
          LOG(`cache hit: size=${cached.content.size} fileName=${cached.fileName}`);
          blob = cached.content;
          if (cached.fileName) openFileName = cached.fileName;
        } else {
          LOG('cache miss');
        }
      } catch (e) {
        LOG_ERR('cache read error:', e);
      }
    }

    // 3. TOS + 缓存都未命中,从后端下载原始文件
    if (!blob) {
      loadingText.value = "正在从服务器下载文档...";
      LOG(`downloading original file`);
      if (props.isChatAnswer) {
        const response = await apiGetDraftById(props.fileId);
        if (response && response.data && response.data.content) {
          blob = new Blob([response.data.content], { type: "text/plain" });
          LOG(`draft loaded: size=${blob.size}`);
        } else {
          throw new Error("无法获取草稿内容");
        }
      } else {
        const response = await filesApi.downloadFile(props.fileId);
        if (response instanceof Blob) {
          blob = response;
        } else if (response && response.data instanceof Blob) {
          blob = response.data;
        } else {
          throw new Error("获取文件内容失败");
        }
        LOG(`original downloaded: size=${blob?.size}`);
      }

      if (!blob || blob.size === 0) {
        throw new Error("下载的文件内容为空,请检查网络或文件是否存在");
      }

      // 写入缓存(仅在未命中 TOS 时,即存原始文件)
      try {
        let cacheType = 'word';
        if (['ppt', 'pptx'].includes(fileExt)) cacheType = 'presentation';
        else if (['xls', 'xlsx'].includes(fileExt)) cacheType = 'spreadsheet';
        await fileCache.setFile({ fileId, content: blob, type: cacheType, fileName: openFileName });
        LOG(`cache written: type=${cacheType}`);
      } catch (e) {
        LOG_ERR('cache write error:', e);
      }
    }

    LOG(`blob ready: size=${blob.size} openFileName=${openFileName}`);
    loadingText.value = `文件已就绪 (${(blob.size / 1024 / 1024).toFixed(2)}MB),正在本地解析渲染...`;

    // 3. 创建 File 对象
    const file = new File([blob], openFileName, { type: blob.type });

    // 销毁之前的实例
    if (editorInstance) {
      try { editorInstance.destroyEditor(); } catch (e) {}
      editorInstance = null;
    }

    // 4. 使用本地转换器和 OnlyOffice API 打开文档(只读预览,不支持修改)
    LOG(`calling openDocument: elementId=onlyoffice-editor-${props.fileId}`);
    editorInstance = await openDocument(file, "onlyoffice-editor-" + props.fileId, () => {
      LOG(`onReady callback fired`);
      loading.value = false;
      isInitializing = false;
    });

    loadingText.value = "正在渲染界面...";
  } catch (err: any) {
    LOG_ERR(`initViewer failed:`, err);
    if (/Conversion failed/i.test(err.message || '')) {
      error.value = `文件解析失败,请重新上传后再试`;
    } else {
      error.value = err.message || "加载失败,请稍后重试";
    }
    loading.value = false;
    isInitializing = false;
  }
};

// 监听文件变化
watch(
  () => props.fileId,
  () => {
    initViewer();
  }
);

onMounted(() => {
  initViewer();
});

onBeforeUnmount(() => {
  if (editorInstance) {
    try {
      editorInstance.destroyEditor();
    } catch (e) {
      // ignore
    }
    editorInstance = null;
  }
});
</script>

<style scoped lang="scss">
.onlyoffice-viewer {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  background-color: var(--color-bg);
  overflow: hidden;
  position: relative;

  .loading-state,
  .error-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    flex: 1;
    gap: 16px;
    color: var(--color-text-secondary);

    i {
      font-size: 48px;
    }

    span,
    .error-text {
      font-size: 14px;
      margin: 0;
    }
  }

  .error-state {
    i {
      color: #f56c6c;
    }
  }

  .editor-container {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    border: none;
    background-color: #fff;

    :deep(iframe) {
      width: 100% !important;
      height: 100% !important;
      border: none !important;
    }
  }
}
</style>