SpreadsheetViewer.vue 9.46 KB
<template>
  <div class="spreadsheet-viewer">
    <!-- 加载状态 -->
    <div v-if="loading" class="loading-state">
      <i class="fas fa-spinner fa-spin"></i>
      <span>加载电子表格中...</span>
    </div>

    <!-- 错误状态 -->
    <div v-else-if="error" class="error-state">
      <i class="fas fa-exclamation-triangle"></i>
      <p class="error-text">{{ error }}</p>
      <div class="error-actions">
        <el-button @click="loadSpreadsheet" size="small">重试</el-button>
        <el-button type="primary" @click="handleDownload" size="small">
          <i class="fas fa-download"></i>
          下载文件
        </el-button>
      </div>
    </div>

    <!-- Univer 容器 -->
    <div v-else class="sheet-container">
      <UniverSheet
        ref="univerRef"
        class="univer-component"
      />
      
      <!-- 自定义下载按钮 (悬浮) -->
      <div class="custom-toolbar">
         <el-tooltip content="下载 Excel 文件" placement="bottom">
            <div class="toolbar-btn" @click="handleDownload">
              <i class="fas fa-download"></i>
            </div>
         </el-tooltip>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, nextTick, watch } from "vue";
import { ElButton, ElMessage, ElTooltip } from "element-plus";
import * as filesApi from "@/api/files";
import UniverSheet from "./UniverSheet.vue";
import { LocaleType } from "@univerjs/core";
import * as XLSX from "xlsx";
import { fileCache } from "@/utils/fileCache";

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

const props = defineProps<Props>();

const univerRef = ref<InstanceType<typeof UniverSheet> | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);

// 下载文件
const handleDownload = async () => {
  try {
    // 如果是 Codex 文件,尝试从缓存读取
    let response;
    if (props.isChatAnswer) {
      try {
        const cached = await fileCache.getFile(props.fileId);
        if (cached && cached.content instanceof Blob) {
          response = cached.content;
        } else {
          response = await filesApi.downloadFile(props.fileId);
        }
      } catch (error) {
        response = await filesApi.downloadFile(props.fileId);
      }
    } else {
      response = await filesApi.downloadFile(props.fileId);
    }

    let blob: Blob;

    if (response instanceof Blob) {
      blob = response;
    } else if (response.data instanceof Blob) {
      blob = response.data;
    } else if (props.isChatAnswer && response.data?.content) {
      blob = new Blob([response.data.content], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
    } else {
      throw new Error("下载响应格式错误");
    }

    const fileName = props.fileName || String(props.fileId);
    const a = document.createElement("a");
    a.href = window.URL.createObjectURL(blob);
    a.download = fileName;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    window.URL.revokeObjectURL(a.href);
    ElMessage.success("文件下载成功");
  } catch (e: any) {
    ElMessage.error(`文件下载失败: ${e.message}`);
  }
};

// 简单的转换逻辑,确保能预览数据
const convertXlsxToUniverData = (workbook: XLSX.WorkBook) => {
  const sheets: any = {};
  const sheetOrder: string[] = [];

    workbook.SheetNames.forEach((sheetName) => {
      const worksheet = workbook.Sheets[sheetName];
      if (!worksheet) return;

      const sheetId = "sheet-" + sheetName.replace(/\s+/g, "_");
      sheetOrder.push(sheetId);

      const cellData: any = {};
      const range = worksheet["!ref"] ? XLSX.utils.decode_range(worksheet["!ref"]) : { s: { r: 0, c: 0 }, e: { r: 10, c: 10 } };

      for (let R = range.s.r; R <= range.e.r; ++R) {
        const rowData: any = {};
        let hasRowData = false;

        for (let C = range.s.c; C <= range.e.c; ++C) {
          const cellAddress = XLSX.utils.encode_cell({ r: R, c: C });
          const cell = worksheet[cellAddress];

          if (cell && cell.v !== undefined) {
            rowData[C] = {
              v: cell.v,
              m: cell.w !== undefined ? String(cell.w) : String(cell.v),
            };
            hasRowData = true;
          }
        }

        if (hasRowData) {
          cellData[R] = rowData;
        }
      }

      sheets[sheetId] = {
        id: sheetId,
        name: sheetName,
        cellData,
        rowCount: Math.max(range.e.r + 20, 100),
        columnCount: Math.max(range.e.c + 10, 20),
      };
    });

    if (sheetOrder.length === 0) {
      // 如果没有工作表,创建一个默认的
      const defaultId = "sheet-1";
      sheetOrder.push(defaultId);
      sheets[defaultId] = {
        id: defaultId,
        name: "Sheet1",
        cellData: {},
        rowCount: 100,
        columnCount: 20,
      };
    }

    const univerData = {
      id: "workbook-" + Date.now(),
      sheetOrder,
      sheets,
      appVersion: "3.0.0",
      locale: LocaleType.ZH_CN,
    };
  return univerData;
};

// 加载电子表格
const loadSpreadsheet = async () => {
  loading.value = true;
  error.value = null;

  try {
    const fileId = props.fileId;
    let blob: Blob | null = null;

    // 1. 尝试从持久化缓存读取
    try {
      const cached = await fileCache.getFile(fileId);
      if (cached && cached.content instanceof Blob) {
        console.log("电子表格命中持久化缓存:", fileId);
        blob = cached.content;
      }
    } catch (e) {
      console.warn("读取电子表格持久化缓存失败:", e);
    }

    if (!blob) {
      // 1. 下载文件
      let response;
      if (props.isChatAnswer) {
        try {
          const cached = await fileCache.getFile(props.fileId);
          if (cached && cached.content instanceof Blob) {
            response = cached.content;
          } else {
            response = await filesApi.downloadFile(props.fileId);
          }
        } catch (error) {
          response = await filesApi.downloadFile(props.fileId);
        }
      } else {
        response = await filesApi.downloadFile(props.fileId);
      }
      
      if (response instanceof Blob) {
        blob = response;
      } else if (response.data instanceof Blob) {
        blob = response.data;
      } else if (props.isChatAnswer && response.data?.content) {
        blob = new Blob([response.data.content], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
      } else {
        throw new Error("下载响应格式错误");
      }

      // 写入持久化缓存
      if (blob instanceof Blob) {
        await fileCache.setFile({
          fileId,
          content: blob,
          type: "spreadsheet",
          fileName: props.fileName,
        });
      }
    }
    
    // 2. 解析数据 (在 loading 关闭前完成解析)
    const arrayBuffer = await blob.arrayBuffer();
    
    // 检查是否真的是 Excel 文件格式 (Zip 结构)
    const fileName = props.fileName || "";
    const fileExt = fileName.split(".").pop()?.toLowerCase() || "";
    
    if (fileExt === 'xls') {
       throw new Error("暂不支持预览旧版 .xls 格式。请将其另存为 .xlsx 格式后再上传,或直接下载查看。");
    }

    const wb = XLSX.read(arrayBuffer, { type: "array" });
    const univerData = convertXlsxToUniverData(wb);
    
    // 3. 关闭加载状态,显示 UniverSheet 组件
    loading.value = false;
    
    // 4. 等待 DOM 渲染和 UniverSheet 挂载
    await nextTick();
    
    if (univerRef.value) {
        univerRef.value.createWorkbook(univerData);
        ElMessage.success("文件加载成功");
    } else {
        error.value = "组件初始化失败";
    }

  } catch (err: any) {
    console.error("❌ 加载电子表格失败:", err);
    error.value = `加载失败: ${err.message || "未知错误"}`;
    loading.value = false;
  }
};

// 监听 fileId 变化
watch(
  () => props.fileId,
  async (newFileId, oldFileId) => {
    if (newFileId !== oldFileId) {
       await loadSpreadsheet();
    }
  },
);

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

</script>

<style scoped lang="scss">
.spreadsheet-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;
    }
    .error-actions {
      display: flex;
      gap: 12px;
      margin-top: 8px;
    }
  }

  .sheet-container {
    width: 100%;
    height: 100%;
    position: relative;
    
    .univer-component {
        width: 100%;
        height: 100%;
    }
  }

  .custom-toolbar {
      position: absolute;
      top: 10px;
      right: 20px;
      z-index: 100;
      display: flex;
      gap: 8px;

      .toolbar-btn {
          width: 32px;
          height: 32px;
          border-radius: 4px;
          background: none;
          display: flex;
          align-items: center;
          justify-content: center;
          cursor: pointer;
          color: var(--color-text, #606266);
          transition: all 0.2s;
          padding: 6px;
          border: none;

          &:hover {
              background: rgba(0, 0, 0, 0.05);
          }
      }
  }
}
</style>