useFileOperation.js 12.7 KB
/**
 * 统一的文件操作 Composable
 *
 * @description 提供文件下载、打开、预览等统一操作逻辑
 * 处理 PDF、Office 文档、视频等多种文件格式的预览和下载
 *
 * @author Claude Code
 * @date 2026-01-31
 */

import Taro from '@tarojs/taro'
import { showToast, showLoading, hideLoading, showModal, openDocument, downloadFile, previewImage } from '@tarojs/taro'
import { isVideoFile } from '@/utils/tools'
import { extractExtensionFromFile } from '@/utils/documentIcons'

/**
 * 文件操作 Hook
 *
 * @returns {Object} 文件操作方法集合
 */
export function useFileOperation() {
  /**
   * 判断是否为图片文件
   *
   * @description 支持传入文件名或包含 extension 字段的对象,优先使用 extension 字段
   * @param {string|Object} fileNameOrItem - 文件名或文件对象
   * @param {string} [fileNameOrItem.fileName] - 文件名
   * @param {string} [fileNameOrItem.extension] - 文件扩展名(优先使用)
   * @returns {boolean} 是否为图片文件
   */
  const isImageFile = (fileNameOrItem) => {
    const extension = extractExtensionFromFile(fileNameOrItem)

    if (!extension) return false

    const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
    return imageExtensions.includes(extension)
  }
  /**
   * 打开文件的通用函数
   *
   * @description 使用 Taro.openDocument 打开文件,支持菜单转发和保存
   * @async
   * @param {string} filePath - 本地文件路径
   * @param {Object} item - 文件信息对象
   * @param {string} [item.fileName] - 文件名(用于判断文件类型)
   * @param {string} [item.extension] - 文件扩展名(优先使用)
   * @param {string} [item.downloadUrl] - 原始下载地址(用于失败时复制链接)
   * @returns {Promise<void>}
   *
   * @example
   * const { openFile } = useFileOperation()
   * await openFile(tempFilePath, { fileName: 'document.pdf' })
   * await openFile(tempFilePath, { extension: 'pdf' }) // 优先使用 extension
   */
  const openFile = async (filePath, item) => {
    // 记录文件打开开始的日志
    console.log('[文件操作] 开始打开文件:', {
      filePath,
      fileName: item.fileName,
      extension: item.extension,
      downloadUrl: item.downloadUrl
    })

    try {
      await openDocument({
        filePath: filePath,
        showMenu: true, // 显示右上角菜单,用户可以转发、保存等
        success: () => {
          // 记录成功回调
          console.log('[文件操作] ✅ openDocument success 回调触发 - 文件已打开')

          // 文件打开后,延迟提示用户如果看不到内容该如何操作
          // 使用统一的扩展名提取函数
          const fileExt = extractExtensionFromFile(item)
          const unsupportedFormats = ['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls']

          if (unsupportedFormats.includes(fileExt)) {
            console.log('[文件操作] ⚠️ 检测到不支持的格式:', fileExt, '- 将在 1.5s 后显示提示')

            setTimeout(() => {
              showToast({
                title: '如无法预览,请使用右上角菜单分享',
                icon: 'none',
                duration: 3000
              })
            }, 1500)
          }
        },
        fail: (err) => {
          // 记录失败回调(这个通常不会在"内容无法显示"时触发)
          console.log('[文件操作] ❌ openDocument fail 回调触发:', err)
          console.log('[文件操作] 错误详情:', {
            errMsg: err.errMsg,
            errorCode: err.errCode
          })

          // 获取文件扩展名(使用统一的扩展名提取函数)
          const fileExt = extractExtensionFromFile(item)

          // 根据文件类型给出提示
          let message = '文件打开失败'
          let suggestion = ''
          let showCopyButton = false

          if (['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls'].includes(fileExt)) {
            message = '暂不支持预览 Office 文档'
            suggestion = '\n\n您可以复制链接,在电脑或其他应用中打开'
            showCopyButton = true
          } else if (['pdf'].includes(fileExt)) {
            message = 'PDF 文件打开失败'
            suggestion = '\n\n文件可能已损坏,请联系管理员'
          } else {
            message = `暂不支持预览 ${fileExt.toUpperCase()} 格式文件`
            suggestion = '\n\n请在电脑或其他应用中打开'
            showCopyButton = !!item.downloadUrl
          }

          // 构建 showModal 参数
          const modalParams = {
            title: '提示',
            content: message + suggestion,
            confirmText: showCopyButton ? '复制链接' : '我知道了'
          }

          // 只在有下载链接时才显示取消按钮
          if (showCopyButton) {
            modalParams.cancelText = '关闭'
            modalParams.showCancel = true
          }

          showModal({
            ...modalParams,
            success: (modalRes) => {
              console.log('[文件操作] 用户选择:', modalRes.confirm ? '复制链接' : '关闭')

              if (modalRes.confirm && showCopyButton && item.downloadUrl) {
                // 复制下载链接到剪贴板
                console.log('[文件操作] 开始复制链接:', item.downloadUrl)

                Taro.setClipboardData({
                  data: item.downloadUrl,
                  success: () => {
                    console.log('[文件操作] ✅ 链接复制成功')
                    showToast({
                      title: '链接已复制',
                      icon: 'success',
                      duration: 2000
                    })
                  },
                  fail: (clipboardErr) => {
                    console.log('[文件操作] ❌ 链接复制失败:', clipboardErr)
                    showToast({
                      title: '复制失败',
                      icon: 'none',
                      duration: 2000
                    })
                  }
                })
              }
            }
          })
        }
      })
    } catch (error) {
      // 记录异常(Promise rejection)
      console.log('[文件操作] ❌ openDocument 异常捕获:', error)
      showToast({
        title: '打开文件失败',
        icon: 'none',
        duration: 2000
      })
    }
  }

  /**
   * 下载并打开文件的内部函数
   *
   * @description 先下载文件到本地临时路径,再调用 openFile 打开
   * @async
   * @param {Object} item - 文件信息对象
   * @param {string} item.downloadUrl - 文件下载地址
   * @param {string} [item.fileName] - 文件名
   * @param {string} [item.extension] - 文件扩展名(优先使用)
   * @returns {Promise<void>}
   *
   * @example
   * const { downloadAndOpenFile } = useFileOperation()
   * await downloadAndOpenFile({
   *   downloadUrl: 'https://example.com/file.pdf',
   *   fileName: 'document.pdf'
   * })
   * await downloadAndOpenFile({
   *   downloadUrl: 'https://example.com/file.pdf',
   *   extension: 'pdf' // 优先使用 extension
   * })
   */
  const downloadAndOpenFile = async (item) => {
    try {
      console.log('[文件操作] 开始下载文件:', {
        downloadUrl: item.downloadUrl,
        fileName: item.fileName
      })

      // 下载文件
      const downloadResult = await downloadFile({
        url: item.downloadUrl
      })

      console.log('[文件操作] 下载结果:', {
        statusCode: downloadResult.statusCode,
        tempFilePath: downloadResult.tempFilePath
      })

      // 检查下载结果
      if (downloadResult.statusCode !== 200) {
        throw new Error(`打开失败: HTTP ${downloadResult.statusCode}`)
      }

      if (!downloadResult.tempFilePath) {
        throw new Error('打开失败: 未获取到文件')
      }

      // 隐藏加载提示
      hideLoading()

      // 获取文件扩展名(使用统一的扩展名提取函数)
      const fileExt = extractExtensionFromFile(item)

      // 微信小程序对 Office 文档支持有限,提前提示用户
      const unsupportedFormats = ['docx', 'doc', 'pptx', 'ppt', 'xlsx', 'xls']
      const dontShowAgainKey = 'office_doc_tip_dont_show_again'

      // 检查用户是否已选择"不再提醒"
      const dontShowAgain = Taro.getStorageSync(dontShowAgainKey)
      console.log('[文件操作] "不再提醒"设置状态:', dontShowAgain)

      if (unsupportedFormats.includes(fileExt) && !dontShowAgain) {
        // 首次打开 Office 文档,显示提示
        console.log('[文件操作] 首次打开 Office 文档,显示提示')

        showModal({
          title: '预览提示',
          content: `小程序对 ${fileExt.toUpperCase()} 文档的预览支持有限,如果显示为空白,请点击右上角"..."菜单,选择"发送给朋友"后在电脑或其他应用中打开。\n\n是否继续预览?`,
          confirmText: '继续预览',
          cancelText: '取消',
          success: (modalRes) => {
            console.log('[文件操作] 用户选择:', modalRes.confirm ? '继续预览' : '取消')

            if (modalRes.confirm) {
              // 记录用户选择,下次不再提示
              try {
                Taro.setStorageSync(dontShowAgainKey, true)
                console.log('[文件操作] ✅ 已保存"不再提醒"设置')
              } catch (storageErr) {
                console.log('[文件操作] ❌ 保存设置失败:', storageErr)
              }

              // 打开文件
              openFile(downloadResult.tempFilePath, item)
            }
          }
        })
      } else {
        // 用户已选择"不再提醒"或非 Office 文档,直接打开
        console.log('[文件操作] 直接打开文件(已选择不再提醒 或 非 Office 文档)')
        await openFile(downloadResult.tempFilePath, item)
      }
    } catch (error) {
      // 确保隐藏加载提示
      hideLoading()

      console.error('打开文件出错:', error)

      // 根据错误类型显示不同的提示
      let errorMessage = '打开失败,请重试'
      if (error.errMsg && error.errMsg.includes('network')) {
        errorMessage = '网络连接失败,请检查网络'
      } else if (error.errMsg && error.errMsg.includes('TLS')) {
        errorMessage = '安全连接失败,请检查网络'
      }

      showToast({
        title: errorMessage,
        icon: 'none',
        duration: 2000
      })
    }
  }

  /**
   * 查看文件(入口函数)
   *
   * @description 检查文件是否有下载地址,判断文件类型:
   * - 图片文件:使用 Taro.previewImage 预览
   * - 视频文件:跳转播放页面
   * - 其他文件:下载并使用 Taro.openDocument 打开
   * @async
   * @param {Object} item - 文件信息对象
   * @param {string} [item.downloadUrl] - 文件下载地址
   * @param {string} [item.fileName] - 文件名
   * @param {string} [item.extension] - 文件扩展名(优先使用)
   * @returns {Promise<void>}
   *
   * @example
   * const { viewFile } = useFileOperation()
   * await viewFile({
   *   downloadUrl: 'https://example.com/file.pdf',
   *   fileName: 'document.pdf'
   * })
   * await viewFile({
   *   downloadUrl: 'https://example.com/file.pdf',
   *   extension: 'pdf' // 优先使用 extension
   * })
   */
  const viewFile = async (item) => {
    // 检查是否有下载地址
    if (!item.downloadUrl) {
      showToast({
        title: '该文件暂无查看地址',
        icon: 'none',
        duration: 2000
      })
      return
    }

    // 判断是否为图片文件(优先使用 extension 字段)
    if (isImageFile(item)) {
      // 图片文件:使用图片预览
      console.log('[文件操作] 检测到图片文件,使用图片预览')
      try {
        await previewImage({
          urls: [item.downloadUrl],
          current: item.downloadUrl
        })
      } catch (error) {
        console.error('[文件操作] 图片预览失败:', error)
        showToast({
          title: '图片预览失败',
          icon: 'none',
          duration: 2000
        })
      }
      return
    }

    // 判断是否为视频文件(优先使用 extension 字段)
    if (isVideoFile(item)) {
      // 视频文件:跳转到视频播放页面
      Taro.navigateTo({
        url: `/pages/video-player/index?url=${encodeURIComponent(item.downloadUrl)}&title=${encodeURIComponent(item.title || item.fileName)}`
      })
      return
    }

    // 非视频文件:下载并打开
    // 显示加载提示
    showLoading({
      title: '打开中...',
      mask: true
    })

    // 下载并打开文件
    await downloadAndOpenFile(item)
  }

  return {
    openFile,
    downloadAndOpenFile,
    viewFile
  }
}