index.vue 8.19 KB
<!--
 * @Description: 统一文档预览组件
 * @Date: 2025-01-30
 * @Features:
 * - H5 环境:使用 OfficeViewer + PdfPreview 组件
 * - 小程序环境:根据文件大小自动选择预览方式
 *   - 小于 10MB:微信原生 API (wx.openDocument)
 *   - 大于等于 10MB:web-view + 腾讯文档预览
-->
<template>
  <view class="document-preview">
    <!-- #ifdef H5 -->
    <!-- H5 环境:使用现有组件 -->
    <OfficeViewer
      v-if="isOfficeDocument && src"
      :src="src"
      :fileType="finalFileType"
      @rendered="handleRendered"
      @error="handleError"
    />

    <PdfPreview
      v-if="isPdfDocument && src"
      :url="src"
      :show="show"
      @update:show="handleUpdateShow"
      @onLoad="handlePdfLoad"
    />
    <!-- #endif -->

    <!-- #ifdef WEAPP -->
    <!-- 小程序环境:使用微信原生 API 或 web-view -->
    <view class="preview-container">
      <!-- 加载状态 -->
      <view v-if="loading" class="loading-container">
        <IconFont name="loading" size="24" class="animate-spin text-blue-600" />
        <text class="loading-text">{{ loadingText }}</text>
      </view>

      <!-- 错误状态 -->
      <view v-else-if="error" class="error-container">
        <IconFont name="issue" size="48" color="#ff6b6b" />
        <text class="error-text">{{ error }}</text>
        <nut-button type="primary" size="small" @click="retry">
          重试
        </nut-button>
      </view>

      <!-- 预览按钮 -->
      <view v-else class="action-container">
        <view class="file-info">
          <IconFont :name="fileIcon" size="64" class="text-blue-600" />
          <text class="file-name">{{ fileName || '未知文件' }}</text>
          <text class="file-size">{{ formatFileSize(fileSize) }}</text>
        </view>

        <nut-button
          type="primary"
          block
          @click="openDocument"
          :loading="loading"
        >
          {{ previewButtonText }}
        </nut-button>

        <text v-if="needWebView" class="hint-text">
          大文件将使用在线预览
        </text>
      </view>
    </view>
    <!-- #endif -->
  </view>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import { getFileSize, detectFileType, formatFileSize } from './utils'
import { IconFont } from '@nutui/icons-vue-taro'

// #ifdef H5
import OfficeViewer from '../OfficeViewer.vue'
import PdfPreview from '../PdfPreview.vue'
// #endif

// #ifdef WEAPP
import Taro from '@tarojs/taro'
// #endif

// Props 定义
const props = defineProps({
  // 文档 URL
  src: {
    type: String,
    required: true
  },
  // 文件类型(可选,自动检测)
  fileType: {
    type: String,
    default: ''
  },
  // 是否显示(H5 PDF 预览用)
  show: {
    type: Boolean,
    default: false
  },
  // 文件名(用于显示)
  fileName: {
    type: String,
    default: ''
  }
})

// Emits 定义
const emit = defineEmits(['rendered', 'error', 'update:show'])

const finalFileType = computed(() => {
  const detectedType = detectFileType(props.src)
  return (props.fileType || detectedType || '').toLowerCase()
})

const isOfficeDocument = computed(() => {
  return ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(finalFileType.value)
})

const isPdfDocument = computed(() => {
  return finalFileType.value === 'pdf'
})

// #ifdef WEAPP
// 响应式数据
const loading = ref(false)
const loadingText = ref('准备中...')
const error = ref('')
const fileSize = ref(0)

// 计算属性
const needWebView = computed(() => fileSize.value === 0 || fileSize.value >= 10 * 1024 * 1024)

const fileIcon = computed(() => {
  const type = finalFileType.value.toLowerCase()
  const iconMap = {
    pdf: 'Order',
    doc: 'Edit',
    docx: 'Edit',
    xls: 'Category',
    xlsx: 'Category',
    ppt: 'PlayCircleFill',
    pptx: 'PlayCircleFill'
  }
  return iconMap[type] || 'Link'
})

const previewButtonText = computed(() => {
  return needWebView.value ? '在线预览文档' : '打开文档'
})

/**
 * 初始化文件信息
 */
const initFileInfo = async () => {
  if (!props.src) return

  loading.value = true
  loadingText.value = '检测文件...'
  error.value = ''

  try {
    // 获取文件大小
    loadingText.value = '获取文件信息...'
    const size = await getFileSize(props.src)
    fileSize.value = size

    console.log('文件信息:', {
      url: props.src,
      type: finalFileType.value,
      size: size,
      needWebView: needWebView.value
    })
  } catch (err) {
    console.error('获取文件信息失败:', err)
    error.value = '无法获取文件信息,请检查网络连接'
  } finally {
    loading.value = false
  }
}

/**
 * 打开文档
 */
const openDocument = async () => {
  loading.value = true
  loadingText.value = needWebView.value ? '跳转到在线预览...' : '下载中...'
  error.value = ''

  try {
    if (needWebView.value) {
      // 大文件:使用 web-view 在线预览
      await openWithWebView()
    } else {
      // 小文件:使用微信原生 API
      await openWithNativeAPI()
    }
  } catch (err) {
    console.error('打开文档失败:', err)
    error.value = err.message || '文档打开失败,请重试'
  } finally {
    loading.value = false
  }
}

/**
 * 使用微信原生 API 打开文档
 */
const openWithNativeAPI = async () => {
  try {
    // 下载文件
    const downloadRes = await Taro.downloadFile({
      url: props.src,
      timeout: 30000 // 30秒超时
    })

    if (downloadRes.statusCode !== 200) {
      throw new Error('文件下载失败')
    }

    // 打开文档
    await Taro.openDocument({
      filePath: downloadRes.tempFilePath,
      fileType: finalFileType.value
    })

    console.log('文档打开成功')
  } catch (err) {
    console.error('微信原生 API 打开文档失败:', err)
    throw new Error('文档打开失败: ' + (err.errMsg || err.message))
  }
}

/**
 * 使用 web-view 在线预览
 */
const openWithWebView = async () => {
  try {
    // 跳转到 web-view 容器页面
    const previewUrl = encodeURIComponent(props.src)
    const fileType = encodeURIComponent(finalFileType.value)

    await Taro.navigateTo({
      url: `/pages/document-preview/index?url=${previewUrl}&type=${fileType}`
    })

    console.log('跳转到在线预览页面')
  } catch (err) {
    console.error('跳转失败:', err)
    throw new Error('打开预览页面失败')
  }
}

/**
 * 重试
 */
const retry = () => {
  initFileInfo()
}

// 监听 src 变化
watch(() => props.src, (newSrc) => {
  if (newSrc) {
    initFileInfo()
  }
}, { immediate: true })
// #endif

// #ifdef H5
const handleRendered = () => {
  console.log('H5: 文档渲染完成')
  emit('rendered')
}

const handleError = (err) => {
  console.error('H5: 文档渲染失败', err)
  emit('error', err)
}

const handleUpdateShow = (value) => {
  emit('update:show', value)
}

const handlePdfLoad = () => {
  console.log('H5: PDF 加载完成')
}
// #endif
</script>

<style lang="less" scoped>
.document-preview {
  width: 100%;
  height: 100%;
  background: #f5f5f5;
}

// #ifdef WEAPP
.preview-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 800rpx;
  padding: 60rpx;
  background: #fff;
  border-radius: 32rpx;
}

.loading-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 40rpx;

  .loading-text {
    font-size: 56rpx;
    color: #999;
  }
}

.error-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 40rpx;
  padding: 80rpx 40rpx;

  .error-text {
    font-size: 56rpx;
    color: #666;
    text-align: center;
    line-height: 1.6;
  }
}

.action-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 60rpx;
  width: 100%;
  max-width: 1000rpx;

  .file-info {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 40rpx;
    padding: 80rpx;
    background: #f8f9fa;
    border-radius: 32rpx;
    width: 100%;

    .file-name {
      font-size: 64rpx;
      font-weight: 500;
      color: #333;
      text-align: center;
      word-break: break-all;
    }

    .file-size {
      font-size: 48rpx;
      color: #999;
    }
  }

  .hint-text {
    font-size: 48rpx;
    color: #ff9800;
    text-align: center;
  }
}
// #endif
</style>