hookehuyr

feat: 新增文档预览组件及相关页面

- 新增 DocumentPreview 统一文档预览组件,支持 PDF/Word/Excel/PPT 格式
- 新增文档预览页面和示例页面,支持大文件在线预览
- 新增 OfficeViewer 和 PdfPreview 组件用于 H5 环境
- 新增文档预览工具函数,支持文件类型检测和大小获取
- 配置 ESLint 以支持 Vue 项目,修复相关依赖问题
- 更新 IconFont 组件,添加文档预览所需图标
- 在应用配置中注册新增页面路由
......@@ -50,7 +50,11 @@
"Bash(git checkout:*)",
"Bash(python3:*)",
"Bash(./test-mcp-connection.sh:*)",
"Bash(timeout:*)"
"Bash(timeout:*)",
"Bash(pnpm lint:*)",
"Bash(pnpm dev:weapp:*)",
"Bash(tee:*)",
"Bash(node:*)"
]
}
}
......
module.exports = {
root: true,
env: {
node: true,
es2021: true
},
globals: {
definePageConfig: 'readonly',
getCurrentPages: 'readonly',
ENABLE_AUTH_MODE: 'readonly',
wx: 'readonly'
},
extends: ['taro'],
rules: {
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off',
'vue/multi-word-component-names': 'off',
'import/first': 'off',
'import/newline-after-import': 'off',
'import/no-duplicates': 'off',
'import/no-mutable-exports': 'off',
'no-unused-vars': 'warn'
},
overrides: [
{
files: ['**/*.vue'],
extends: ['taro/vue3'],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@babel/eslint-parser',
requireConfigFile: false,
ecmaVersion: 2021,
sourceType: 'module'
},
rules: {
'vue/multi-word-component-names': 'off'
}
},
{
files: ['**/*.js'],
parser: '@babel/eslint-parser',
parserOptions: {
requireConfigFile: false,
ecmaVersion: 2021,
sourceType: 'module'
}
}
]
}
......@@ -7,14 +7,19 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
DocumentPreview: typeof import('./src/components/DocumentPreview/index.vue')['default']
IconFont: typeof import('./src/components/IconFont.vue')['default']
IndexNav: typeof import('./src/components/indexNav.vue')['default']
NavHeader: typeof import('./src/components/NavHeader.vue')['default']
NutAvatar: typeof import('@nutui/nutui-taro')['Avatar']
NutButton: typeof import('@nutui/nutui-taro')['Button']
NutSearchbar: typeof import('@nutui/nutui-taro')['Searchbar']
NutTabPane: typeof import('@nutui/nutui-taro')['TabPane']
NutTabs: typeof import('@nutui/nutui-taro')['Tabs']
NutTextarea: typeof import('@nutui/nutui-taro')['Textarea']
NutUploader: typeof import('@nutui/nutui-taro')['Uploader']
OfficeViewer: typeof import('./src/components/OfficeViewer.vue')['default']
PdfPreview: typeof import('./src/components/PdfPreview.vue')['default']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
QrCode: typeof import('./src/components/qrCode.vue')['default']
......
......@@ -115,3 +115,14 @@ All notable changes to this project will be documented in this file.
- 移除 `src/api/index.js` 中的离线专用接口定义
- 更新配置文件,移除 `ENABLE_OFFLINE_MODE` 开关
- 修复构建告警:移除首页残留的 `ENABLE_OFFLINE_MODE``@/utils/uiText` 引用
### Fixed
- 修复 ESLint 无法解析 Vue SFC 导致 lint 全量报错:补齐 ESLint 配置与 Vue 解析依赖
- 修复 eslint-config-taro 在 Vue 项目中触发 React Hooks 规则导致误报的问题
### Changed
- 优化 DocumentPreview 小程序端预览策略:无法获取文件大小时默认走在线预览
- 将 DocumentPreview 小程序端样式单位统一为 rpx
### Added
- 补全文档预览示例页的 Excel / PPT 在线示例链接
......
......@@ -41,6 +41,7 @@
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.7.7",
"@nutui/icons-vue": "^0.1.1",
"@nutui/icons-vue-taro": "^0.0.9",
"@nutui/nutui-taro": "^4.3.13",
"@tarojs/components": "4.1.9",
......@@ -80,6 +81,9 @@
"css-loader": "3.4.2",
"eslint": "^8.12.0",
"eslint-config-taro": "4.1.9",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.4.0",
"eslint-plugin-vue": "^8.0.0",
"js-yaml": "^4.1.1",
"less": "^4.2.0",
"postcss": "^8.5.6",
......@@ -88,6 +92,7 @@
"tailwindcss": "^3.4.0",
"unplugin-vue-components": "^0.26.0",
"vue-loader": "^17.0.0",
"vue-eslint-parser": "^9.0.0",
"weapp-tailwindcss": "^4.1.10",
"webpack": "5.91.0"
},
......
This diff is collapsed. Click to expand it.
/*
* @Date: 2025-06-28 10:33:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-29 22:47:02
* @LastEditTime: 2026-01-30 10:07:28
* @FilePath: /manulife-weapp/src/app.config.js
* @Description: 小程序配置文件
*/
......@@ -9,6 +9,8 @@ const pages = [
'pages/index/index',
'pages/search/index',
'pages/webview/index',
'pages/document-preview/index',
'pages/document-demo/index',
'pages/auth/index',
'pages/onboarding/index',
'pages/family-office/index',
......
# DocumentPreview 组件使用文档
## 📖 概述
`DocumentPreview` 是一个统一的文档预览组件,支持 **PDF、Word、Excel、PPT** 等多种格式。
### 核心特性
**多环境支持**:H5 + 微信小程序
**智能预览**:根据文件大小自动选择最佳预览方式
**全格式支持**:PDF、Word、Excel、PPT
**优雅降级**:大文件自动使用在线预览
---
## 🚀 快速开始
### 1. 基本使用
```vue
<template>
<view class="page">
<DocumentPreview
:src="documentUrl"
:fileType="fileType"
:fileName="fileName"
@rendered="handleRendered"
@error="handleError"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
import DocumentPreview from '@/components/DocumentPreview/index.vue'
const documentUrl = ref('https://example.com/document.pdf')
const fileType = ref('pdf') // pdf, doc, docx, xls, xlsx, ppt, pptx
const fileName = ref('重要文档.pdf')
const handleRendered = () => {
console.log('文档渲染完成')
}
const handleError = (err) => {
console.error('文档渲染失败:', err)
}
</script>
```
---
## 📋 Props
| 属性 | 类型 | 默认值 | 必填 | 说明 |
|------|------|--------|------|------|
| `src` | `String` | - | ✅ | 文档 URL(必须 HTTPS) |
| `fileType` | `String` | `''` | ❌ | 文档类型(不填则自动检测) |
| `fileName` | `String` | `''` | ❌ | 文件名(用于显示) |
| `show` | `Boolean` | `false` | ❌ | 是否显示(H5 PDF 预览用) |
### fileType 支持的值
- `'pdf'` - PDF 文档
- `'doc'` / `'docx'` - Word 文档
- `'xls'` / `'xlsx'` - Excel 表格
- `'ppt'` / `'pptx'` - PowerPoint 演示文稿
---
## 🎯 Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| `rendered` | - | 文档渲染完成(H5) |
| `error` | `Error` | 文档渲染失败 |
| `update:show` | `Boolean` | 更新显示状态(H5 PDF) |
---
## 🔍 预览策略
### 小程序环境
| 文件大小 | 预览方式 | 体验 |
|---------|---------|------|
| **< 10MB** | 微信原生 API (`wx.openDocument`) | ⭐⭐⭐ 跳转新页面 |
| **≥ 10MB** | web-view + 腾讯文档预览 | ⭐⭐⭐⭐⭐ 在线预览 |
### H5 环境
- **PDF**:使用 `PdfPreview` 组件(内嵌预览)
- **Office 文档**:使用 `OfficeViewer` 组件(内嵌预览)
---
## 💡 使用场景
### 场景 1:预览小文件 PDF
```vue
<DocumentPreview
src="https://example.com/small.pdf"
fileType="pdf"
fileName="产品手册.pdf"
/>
```
**小程序**:使用微信原生 API(快速)✨
**H5**:内嵌预览(优雅)✨
---
### 场景 2:预览大文件 Word
```vue
<DocumentPreview
src="https://example.com/large-document.docx"
fileType="docx"
fileName="大型技术文档.docx"
/>
```
**小程序**:自动跳转到腾讯文档在线预览(支持大文件)🚀
**H5**:内嵌预览
---
### 场景 3:自动检测文件类型
```vue
<DocumentPreview
src="https://example.com/document.xlsx"
fileName="销售数据表"
/>
```
不指定 `fileType`,组件会自动从 URL 中提取文件类型。
---
## ⚙️ 高级配置
### 域名白名单配置
**小程序环境必须配置业务域名白名单**
1. 登录[微信公众平台](https://mp.weixin.qq.com/)
2. 进入「开发」→「开发管理」→「开发设置」
3. 找到「业务域名」
4. 添加以下域名:
- `view.officeapps.live.com`(腾讯文档预览)
- 您自己的文档服务器域名(如 `cdn.example.com`
### HTTPS 要求
**文档 URL 必须使用 HTTPS 协议**
```javascript
// ✅ 正确
const url = 'https://cdn.example.com/document.pdf'
// ❌ 错误(HTTP 不支持)
const url = 'http://cdn.example.com/document.pdf'
```
---
## 🐛 故障排查
### 问题 1:小程序提示"无法打开文档"
**原因**:域名未配置白名单
**解决**
1. 在微信公众平台配置业务域名
2. 确保文档 URL 使用 HTTPS
3. 清除小程序缓存重新打开
---
### 问题 2:大文件预览失败
**原因**:文件超过 10MB,但未正确跳转到 web-view
**解决**
1. 检查 `pages/document-preview/index` 是否在 `app.config.js` 中注册
2. 检查 web-view 域名是否在白名单中
3. 查看控制台错误日志
---
### 问题 3:文件类型检测失败
**原因**:URL 中没有文件扩展名
**解决**:手动指定 `fileType` 属性
```vue
<DocumentPreview
src="https://api.example.com/download?id=123"
fileType="pdf"
/>
```
---
## 📦 依赖
### 小程序环境
- Taro 4.x(内置)
- web-view 组件(内置)
- 无需额外依赖
### H5 环境
- 使用 iframe 预览(PDF 直开、Office 走腾讯文档预览)
- 无需额外依赖
---
## 🎨 样式自定义
组件使用了 Scoped 样式,如需自定义,可以使用深度选择器:
```vue
<style lang="less" scoped>
// 自定义加载容器
:deep(.loading-container) {
background: #f0f0f0;
}
// 自定义错误提示
:deep(.error-container) {
background: #fff0f0;
}
</style>
```
---
## 📝 更新日志
### v1.0.0 (2025-01-30)
✨ 新功能:
- 支持多种文档格式预览
- 智能选择预览方式
- H5 + 小程序环境适配
🐛 Bug 修复:
- 修复 PdfPreview 组件 watch 未导入问题
---
## 🤝 贡献
欢迎提交 Issue 和 Pull Request!
---
## 📄 许可
MIT
<!--
* @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 '@/components/IconFont.vue'
// #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>
/**
* @Description: 文档预览工具函数
* @Date: 2025-01-30
*/
// #ifdef WEAPP
import Taro from '@tarojs/taro'
// #endif
/**
* 从 URL 中检测文件类型
* @param {string} url - 文档 URL
* @returns {string} 文件类型(小写)
*/
export function detectFileType(url) {
if (!url) return ''
// 从 URL 中提取扩展名
const match = url.match(/\.([a-z0-9]+)(?:\?|#|$)/i)
if (match && match[1]) {
const ext = match[1].toLowerCase()
// 映射常见扩展名到统一类型
const typeMap = {
pdf: 'pdf',
doc: 'doc',
docx: 'docx',
xls: 'xls',
xlsx: 'xlsx',
ppt: 'ppt',
pptx: 'pptx'
}
return typeMap[ext] || ext
}
// 如果无法从 URL 判断,尝试从 Content-Type 头(需要后端支持)
return ''
}
/**
* 获取文件大小(通过 HEAD 请求)
* @param {string} url - 文档 URL
* @returns {Promise<number>} 文件大小(字节)
*/
export async function getFileSize(url) {
return new Promise((resolve) => {
// #ifdef H5
// H5 环境:使用 fetch HEAD 请求
fetch(url, { method: 'HEAD' })
.then(response => {
const contentLength = response.headers.get('Content-Length')
if (contentLength) {
resolve(parseInt(contentLength, 10))
} else {
// 无法获取大小,返回 0(将使用 web-view)
resolve(0)
}
})
.catch(err => {
console.error('获取文件大小失败:', err)
// 失败时返回 0,将使用 web-view
resolve(0)
})
// #endif
// #ifdef WEAPP
// 小程序环境:使用 Taro.request HEAD 请求
Taro.request({
url: url,
method: 'HEAD',
success: (res) => {
const contentLength = res.header['Content-Length'] || res.header['content-length']
if (contentLength) {
resolve(parseInt(contentLength, 10))
} else {
// 无法获取大小,返回 0(将使用 web-view)
resolve(0)
}
},
fail: (err) => {
console.error('获取文件大小失败:', err)
// 失败时返回 0,将使用 web-view
resolve(0)
}
})
// #endif
})
}
/**
* 格式化文件大小显示
* @param {number} bytes - 文件大小(字节)
* @returns {string} 格式化后的字符串
*/
export function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '未知大小'
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
// 保留两位小数
const formatted = size.toFixed(2).replace(/\.00$/, '')
return `${formatted} ${units[unitIndex]}`
}
/**
* 判断是否为支持的文档类型
* @param {string} fileType - 文件类型
* @returns {boolean}
*/
export function isSupportedDocumentType(fileType) {
const supportedTypes = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']
return supportedTypes.includes(fileType?.toLowerCase())
}
/**
* 获取文件图标名称
* @param {string} fileType - 文件类型
* @returns {string} 图标名称
*/
export function getFileIconName(fileType) {
const iconMap = {
pdf: 'pdf',
doc: 'word',
docx: 'word',
xls: 'excel',
xlsx: 'excel',
ppt: 'ppt',
pptx: 'ppt'
}
return iconMap[fileType?.toLowerCase()] || 'file'
}
/**
* 生成腾讯文档预览 URL
* @param {string} url - 原始文档 URL
* @returns {string} 腾讯文档预览 URL
*/
export function getTencentPreviewUrl(url) {
const encodedUrl = encodeURIComponent(url)
return `https://view.officeapps.live.com/op/view.aspx?src=${encodedUrl}`
}
/**
* 生成微软在线预览 URL
* @param {string} url - 原始文档 URL
* @returns {string} 微软预览 URL
*/
export function getMicrosoftPreviewUrl(url) {
const encodedUrl = encodeURIComponent(url)
return `https://view.officeapps.live.com/op/embed.aspx?src=${encodedUrl}`
}
......@@ -17,12 +17,19 @@ import {
Check,
Checklist,
Clock,
Download,
Edit,
Find,
Home,
Issue,
Link,
Loading,
Location,
My,
Order,
People,
PlayCircleFill,
Refresh,
RectRight,
RectLeft,
Search,
......@@ -58,12 +65,19 @@ const icons = {
Check,
Checklist,
Clock,
Download,
Edit,
Find,
Home,
Issue,
Link,
Loading,
Location,
My,
Order,
People,
PlayCircleFill,
Refresh,
RectRight,
RectLeft,
Search,
......
<template>
<view class="office-viewer">
<view v-if="error" class="error-container">
<IconFont name="Issue" size="24" color="#ff6b6b" />
<text class="error-text">{{ error }}</text>
<nut-button type="primary" size="small" class="retry-btn" @click="retry">重试</nut-button>
</view>
<view v-else class="document-container">
<!-- #ifdef H5 -->
<iframe
v-if="preview_url"
:src="preview_url"
frameborder="0"
class="preview-iframe"
:style="{ height: container_height }"
@load="on_loaded"
/>
<view v-else class="unsupported-container">
<IconFont name="Issue" size="24" color="#ff6b6b" />
<text class="unsupported-text">不支持的文件类型: {{ normalized_type || '未知' }}</text>
</view>
<!-- #endif -->
<!-- #ifdef WEAPP -->
<view class="unsupported-container">
<IconFont name="Issue" size="24" color="#ff6b6b" />
<text class="unsupported-text">小程序不支持内嵌 Office 预览</text>
</view>
<!-- #endif -->
</view>
<view v-if="loading" class="loading-overlay">
<IconFont name="Loading" size="24" class="animate-spin text-blue-600" />
<text class="loading-text">加载中...</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import IconFont from '@/components/IconFont.vue'
import { getTencentPreviewUrl } from '@/components/DocumentPreview/utils'
const props = defineProps({
src: {
type: [String, ArrayBuffer],
required: true
},
fileType: {
type: String,
default: ''
},
height: {
type: String,
default: '70vh'
}
})
const emit = defineEmits(['rendered', 'error', 'retry'])
const loading = ref(false)
const error = ref('')
const container_height = computed(() => props.height)
const normalized_type = computed(() => {
const raw_type = (props.fileType || '').toLowerCase()
if (raw_type === 'doc' || raw_type === 'docx') return 'docx'
if (raw_type === 'xls' || raw_type === 'xlsx') return 'xlsx'
if (raw_type === 'ppt' || raw_type === 'pptx') return 'pptx'
if (raw_type === 'pdf') return 'pdf'
return raw_type
})
const preview_url = computed(() => {
if (!props.src || typeof props.src !== 'string') return ''
if (normalized_type.value === 'pdf') return props.src
if (['docx', 'xlsx', 'pptx'].includes(normalized_type.value)) {
return getTencentPreviewUrl(props.src)
}
return ''
})
const on_loaded = () => {
loading.value = false
emit('rendered')
}
const retry = () => {
loading.value = true
error.value = ''
emit('retry')
}
watch(() => [props.src, props.fileType], () => {
error.value = ''
if (!props.src) {
loading.value = false
error.value = '文档地址不能为空'
emit('error', new Error(error.value))
return
}
loading.value = true
if (!preview_url.value) {
loading.value = false
error.value = '文档类型不支持或地址格式不正确'
emit('error', new Error(error.value))
}
}, { immediate: true })
</script>
<style lang="less" scoped>
.office-viewer {
position: relative;
width: 100%;
height: 100%;
background: #fff;
border-radius: 8px;
overflow: hidden;
.error-container,
.unsupported-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400rpx;
gap: 24rpx;
padding: 40rpx;
.error-text,
.unsupported-text {
font-size: 28rpx;
color: #666;
text-align: center;
line-height: 1.5;
}
.retry-btn {
margin-top: 16rpx;
}
}
.document-container {
width: 100%;
height: 100%;
}
.preview-iframe {
width: 100%;
border: none;
}
.loading-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
background: rgba(255, 255, 255, 0.9);
border-radius: 16rpx;
padding: 24rpx 32rpx;
display: flex;
align-items: center;
gap: 16rpx;
.loading-text {
font-size: 28rpx;
color: #333;
}
}
}
</style>
<!--
* @Date: 2024-01-17
* @Description: PDF预览组件
-->
<template>
<view v-if="show" class="pdf-preview">
<view class="mask" @tap="close"></view>
<view class="panel">
<view class="header">
<text class="title">{{ title || 'PDF 预览' }}</text>
<view class="close" @tap="close">
<IconFont name="Del" size="16" color="#666" />
</view>
</view>
<view class="body">
<!-- #ifdef H5 -->
<iframe
v-if="url"
class="pdf-iframe"
:src="url"
frameborder="0"
@load="on_loaded"
/>
<view v-else class="empty">
<text class="empty-text">PDF 地址不能为空</text>
</view>
<!-- #endif -->
<!-- #ifdef WEAPP -->
<view class="empty">
<text class="empty-text">小程序不支持内嵌 PDF 预览</text>
</view>
<!-- #endif -->
</view>
<view v-if="loading" class="loading">
<IconFont name="Loading" size="24" class="animate-spin text-blue-600" />
<text class="loading-text">加载中...</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
import IconFont from '@/components/IconFont.vue'
const props = defineProps({
show: {
type: Boolean,
default: false
},
url: {
type: String,
default: ''
},
title: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:show', 'onLoad'])
const loading = ref(false)
const close = () => {
emit('update:show', false)
}
const on_loaded = () => {
loading.value = false
emit('onLoad', false)
}
watch(() => props.show, (new_show) => {
if (new_show) {
loading.value = true
} else {
loading.value = false
}
}, { immediate: true })
</script>
<style lang="less" scoped>
.pdf-preview {
position: fixed;
inset: 0;
z-index: 999;
.mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.panel {
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
background: #fff;
display: flex;
flex-direction: column;
}
.header {
height: 96rpx;
padding: 0 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2rpx solid #f1f5f9;
.title {
font-size: 30rpx;
font-weight: 600;
color: #111827;
}
.close {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999rpx;
background: #f3f4f6;
}
}
.body {
flex: 1;
position: relative;
}
.pdf-iframe {
width: 100%;
height: 100%;
border: none;
}
.empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
.empty-text {
font-size: 28rpx;
color: #6b7280;
}
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, 0.9);
border-radius: 16rpx;
padding: 24rpx 32rpx;
display: flex;
align-items: center;
gap: 16rpx;
.loading-text {
font-size: 28rpx;
color: #333;
}
}
}
</style>
/**
* @Description: 文档预览示例页面配置
*/
export default {
navigationBarTitleText: '文档预览示例',
navigationBarBackgroundColor: '#4caf50',
navigationBarTextStyle: 'white',
backgroundColor: '#f5f5f5',
enablePullDownRefresh: false
}
<!--
* @Description: 文档预览示例页面
* @Date: 2025-01-30
* @Usage: 展示 DocumentPreview 组件的各种使用场景
-->
<template>
<view class="document-demo-page">
<nut-tabs v-model="activeTab">
<nut-tab-pane title="PDF 预览" pane-key="pdf">
<DocumentPreview
src="https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
fileType="pdf"
fileName="示例 PDF 文档.pdf"
@rendered="handleRendered"
@error="handleError"
/>
</nut-tab-pane>
<nut-tab-pane title="Word 预览" pane-key="word">
<DocumentPreview
src="https://calibre-ebook.com/downloads/demos/demo.docx"
fileType="docx"
fileName="示例 Word 文档.docx"
@rendered="handleRendered"
@error="handleError"
/>
</nut-tab-pane>
<nut-tab-pane title="Excel 预览" pane-key="excel">
<DocumentPreview
src="https://filesamples.com/samples/document/xlsx/sample1.xlsx"
fileType="xlsx"
fileName="示例 Excel 文档.xlsx"
@rendered="handleRendered"
@error="handleError"
/>
</nut-tab-pane>
<nut-tab-pane title="PPT 预览" pane-key="ppt">
<DocumentPreview
src="https://filesamples.com/samples/document/ppt/sample1.ppt"
fileType="ppt"
fileName="示例 PPT 文档.ppt"
@rendered="handleRendered"
@error="handleError"
/>
</nut-tab-pane>
</nut-tabs>
</view>
</template>
<script setup>
import { ref } from 'vue'
import DocumentPreview from '@/components/DocumentPreview/index.vue'
// #ifdef WEAPP
import Taro from '@tarojs/taro'
// #endif
const activeTab = ref('pdf')
const handleRendered = () => {
console.log('文档渲染完成')
// #ifdef WEAPP
Taro.showToast({
title: '文档加载完成',
icon: 'success'
})
// #endif
}
const handleError = (err) => {
console.error('文档渲染失败:', err)
// #ifdef WEAPP
Taro.showToast({
title: '文档加载失败',
icon: 'error'
})
// #endif
}
</script>
<style lang="less" scoped>
.document-demo-page {
min-height: 100vh;
background: #f5f5f5;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 800rpx;
padding: 40rpx;
gap: 20rpx;
.placeholder-text {
font-size: 32rpx;
font-weight: 500;
color: #333;
}
.placeholder-hint {
font-size: 28rpx;
color: #999;
}
}
</style>
/**
* @Description: 文档预览页面配置
*/
export default {
navigationBarTitleText: '文档预览',
navigationBarBackgroundColor: '#4caf50',
navigationBarTextStyle: 'white',
backgroundColor: '#ffffff',
enablePullDownRefresh: false
}
<!--
* @Description: 文档在线预览页面(web-view 容器)
* @Date: 2025-01-30
* @Usage: 用于大文件(>= 10MB)的在线预览
-->
<template>
<view class="document-preview-page">
<!-- #ifdef WEAPP -->
<web-view :src="previewUrl" @message="handleMessage" @load="handleLoad" @error="handleError" />
<!-- #endif -->
<!-- #ifdef H5 -->
<iframe :src="previewUrl" frameborder="0" class="preview-iframe" />
<!-- #endif -->
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useLoad, useReady } from '@tarojs/taro'
import Taro from '@tarojs/taro'
import { getTencentPreviewUrl } from '@/components/DocumentPreview/utils'
// 响应式数据
const url = ref('')
const fileType = ref('')
const loading = ref(true)
// 计算属性
const previewUrl = computed(() => {
if (!url.value) return ''
const decodedUrl = decodeURIComponent(url.value)
// 根据文件类型选择预览方式
if (fileType.value === 'pdf') {
// PDF 可以直接显示(需要支持跨域)
return decodedUrl
} else {
// Office 文档使用腾讯文档预览
return getTencentPreviewUrl(decodedUrl)
}
})
// 页面加载
useLoad((options) => {
console.log('文档预览页面参数:', options)
if (options.url) {
url.value = options.url
}
if (options.type) {
fileType.value = decodeURIComponent(options.type)
}
// 设置导航栏标题
const titleMap = {
pdf: 'PDF 预览',
doc: 'Word 预览',
docx: 'Word 预览',
xls: 'Excel 预览',
xlsx: 'Excel 预览',
ppt: 'PPT 预览',
pptx: 'PPT 预览'
}
const title = titleMap[fileType.value] || '文档预览'
// #ifdef WEAPP
Taro.setNavigationBarTitle({ title })
// #endif
})
useReady(() => {
console.log('文档预览页面 ready')
})
/**
* web-view 加载完成
*/
const handleLoad = () => {
console.log('web-view 加载完成')
loading.value = false
// #ifdef WEAPP
Taro.hideLoading()
// #endif
}
/**
* web-view 错误
*/
const handleError = (e) => {
console.error('web-view 加载失败:', e)
loading.value = false
// #ifdef WEAPP
Taro.hideLoading()
Taro.showToast({
title: '预览加载失败',
icon: 'none'
})
// #endif
}
/**
* 接收 web-view 消息
*/
const handleMessage = (e) => {
console.log('收到 web-view 消息:', e.detail.data)
}
</script>
<style lang="less" scoped>
.document-preview-page {
width: 100%;
height: 100vh;
background: #fff;
}
// #ifdef WEAPP
web-view {
width: 100%;
height: 100%;
}
// #endif
// #ifdef H5
.preview-iframe {
width: 100%;
height: 100vh;
border: none;
}
// #endif
</style>