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"
},
......
......@@ -11,6 +11,9 @@ importers:
'@babel/runtime':
specifier: ^7.7.7
version: 7.28.6
'@nutui/icons-vue':
specifier: ^0.1.1
version: 0.1.1
'@nutui/icons-vue-taro':
specifier: ^0.0.9
version: 0.0.9
......@@ -122,7 +125,16 @@ importers:
version: 8.57.1
eslint-config-taro:
specifier: 4.1.9
version: 4.1.9(@babel/core@7.28.6)(eslint@8.57.1)(typescript@5.9.3)
version: 4.1.9(@babel/core@7.28.6)(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint-plugin-vue@8.7.1(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3)
eslint-plugin-react:
specifier: ^7.33.2
version: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks:
specifier: ^4.4.0
version: 4.6.2(eslint@8.57.1)
eslint-plugin-vue:
specifier: ^8.0.0
version: 8.7.1(eslint@8.57.1)
js-yaml:
specifier: ^4.1.1
version: 4.1.1
......@@ -144,6 +156,9 @@ importers:
unplugin-vue-components:
specifier: ^0.26.0
version: 0.26.0(@babel/parser@7.28.6)(rollup@3.29.5)(vue@3.5.27(typescript@5.9.3))
vue-eslint-parser:
specifier: ^9.0.0
version: 9.4.3(eslint@8.57.1)
vue-loader:
specifier: ^17.0.0
version: 17.4.2(@vue/compiler-sfc@3.5.27)(vue@3.5.27(typescript@5.9.3))(webpack@5.91.0(@swc/core@1.3.96))
......@@ -1443,6 +1458,9 @@ packages:
'@nutui/icons-vue-taro@0.0.9':
resolution: {integrity: sha512-10VYAtFC+o1X0anGs+y2PgF1NWMeLFz2JVMRw4BWLg6wbtVbYy9wukLxyGhZC6Yf6t39DcwaGVda8paV7K6/Ew==}
'@nutui/icons-vue@0.1.1':
resolution: {integrity: sha512-ekn6R9GNHWNUcV4pxdhQzrI1g1VhZ5zknklXoUJrCzt0RkRRLU0bUXX3ouWT0sl0U3MNJClnXAzZe7iPAvgPtw==}
'@nutui/nutui-taro@4.3.14':
resolution: {integrity: sha512-MeAipTH2i35H1IiZOMDy2yTuIbhl5qk9x4XYnVh0wgjb+zc3oTaJ9mIds3IleryFI0wxYZJsN4xCHIYSEqLutA==}
peerDependencies:
......@@ -2446,6 +2464,10 @@ packages:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'}
array.prototype.findlast@1.2.5:
resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
engines: {node: '>= 0.4'}
array.prototype.findlastindex@1.2.6:
resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==}
engines: {node: '>= 0.4'}
......@@ -2458,6 +2480,10 @@ packages:
resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
engines: {node: '>= 0.4'}
array.prototype.tosorted@1.1.4:
resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==}
engines: {node: '>= 0.4'}
arraybuffer.prototype.slice@1.0.4:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
......@@ -3352,6 +3378,10 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-iterator-helpers@1.2.2:
resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==}
engines: {node: '>= 0.4'}
es-module-lexer@0.10.5:
resolution: {integrity: sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==}
......@@ -3454,6 +3484,24 @@ packages:
'@typescript-eslint/parser':
optional: true
eslint-plugin-react-hooks@4.6.2:
resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==}
engines: {node: '>=10'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
eslint-plugin-react@7.37.5:
resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
engines: {node: '>=4'}
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
eslint-plugin-vue@8.7.1:
resolution: {integrity: sha512-28sbtm4l4cOzoO1LtzQPxfxhQABararUb1JtqusQqObJpWX2e/gmVyeYVfepizPFne0Q5cILkYGiBoV36L12Wg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.2.0 || ^7.0.0 || ^8.0.0
eslint-scope@5.1.1:
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
engines: {node: '>=8.0.0'}
......@@ -3462,6 +3510,12 @@ packages:
resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
eslint-utils@3.0.0:
resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==}
engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0}
peerDependencies:
eslint: '>=5'
eslint-visitor-keys@2.1.0:
resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==}
engines: {node: '>=10'}
......@@ -4280,6 +4334,10 @@ packages:
resolution: {integrity: sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==}
engines: {node: '>= 4'}
iterator.prototype@1.1.5:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
j-component@1.4.9:
resolution: {integrity: sha512-7TaTylECTW4sRaDLaj463sTj9BK6/3rSD67um47ypLPwtZW3wPwynCQ9sdnEJmTIw9Jfy2ZLKWiSDRdaINv50w==}
......@@ -4373,6 +4431,10 @@ packages:
jsonp-retry@1.0.3:
resolution: {integrity: sha512-/jmE9+shtKP+oIt2AWO9Wx+C27NTGpLCEw4QHOqpoV2X6ta374HE9C+EEdgu8r3iLKgFMx7u5j0mCwxWN8UdlA==}
jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
keyv@3.0.0:
resolution: {integrity: sha512-eguHnq22OE3uVoSYG0LVWNP+4ppamWr9+zWBe1bsNcovIMy6huUJFPgy4mGwCd/rnl3vOLGW1MTlu4c57CT1xA==}
......@@ -4642,6 +4704,10 @@ packages:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lower-case@1.1.4:
resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==}
......@@ -4925,6 +4991,10 @@ packages:
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
engines: {node: '>= 0.4'}
object.entries@1.1.9:
resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
engines: {node: '>= 0.4'}
object.fromentries@2.0.8:
resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
engines: {node: '>= 0.4'}
......@@ -5687,6 +5757,9 @@ packages:
promise-polyfill@7.1.2:
resolution: {integrity: sha512-FuEc12/eKqqoRYIGBrUptCBRhobL19PS2U31vMNTfyck1FxPyMfgsXyW4Mav85y/ZN1hop3hOwRlUDok23oYfQ==}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
property-expr@2.0.6:
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
......@@ -5757,6 +5830,9 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
......@@ -5866,6 +5942,10 @@ packages:
engines: {node: '>= 0.4'}
hasBin: true
resolve@2.0.0-next.5:
resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
hasBin: true
responselike@1.0.2:
resolution: {integrity: sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==}
......@@ -6192,6 +6272,13 @@ packages:
string.fromcodepoint@0.2.1:
resolution: {integrity: sha512-n69H31OnxSGSZyZbgBlvYIXlrMhJQ0dQAX1js1QDhpaUH6zmU3QYlj07bCwCNlPOu3oRXIubGPl2gDGnHsiCqg==}
string.prototype.matchall@4.0.12:
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
engines: {node: '>= 0.4'}
string.prototype.repeat@1.0.0:
resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
string.prototype.trim@1.2.10:
resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==}
engines: {node: '>= 0.4'}
......@@ -6630,6 +6717,18 @@ packages:
engines: {node: '>=6.0'}
hasBin: true
vue-eslint-parser@8.3.0:
resolution: {integrity: sha512-dzHGG3+sYwSf6zFBa0Gi9ZDshD7+ad14DGOdTLjruRVgZXe2J+DcZ9iUhyR48z5g1PqRa20yt3Njna/veLJL/g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: '>=6.0.0'
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: '>=6.0.0'
vue-loader@17.4.2:
resolution: {integrity: sha512-yTKOA4R/VN4jqjw4y5HrynFL8AK0Z3/Jt7eOJXEitsm0GMRHDBjCfCiuTiLP7OESvsZYo2pATCWhDqxC5ZrM6w==}
peerDependencies:
......@@ -8220,6 +8319,8 @@ snapshots:
'@nutui/icons-vue-taro@0.0.9': {}
'@nutui/icons-vue@0.1.1': {}
'@nutui/nutui-taro@4.3.14(unplugin-vue-components@0.26.0(@babel/parser@7.28.6)(rollup@3.29.5)(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))':
dependencies:
'@nutui/icons-vue-taro': 0.0.9
......@@ -9432,6 +9533,15 @@ snapshots:
array-union@2.1.0: {}
array.prototype.findlast@1.2.5:
dependencies:
call-bind: 1.0.8
define-properties: 1.2.1
es-abstract: 1.24.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
es-shim-unscopables: 1.1.0
array.prototype.findlastindex@1.2.6:
dependencies:
call-bind: 1.0.8
......@@ -9456,6 +9566,14 @@ snapshots:
es-abstract: 1.24.1
es-shim-unscopables: 1.1.0
array.prototype.tosorted@1.1.4:
dependencies:
call-bind: 1.0.8
define-properties: 1.2.1
es-abstract: 1.24.1
es-errors: 1.3.0
es-shim-unscopables: 1.1.0
arraybuffer.prototype.slice@1.0.4:
dependencies:
array-buffer-byte-length: 1.0.2
......@@ -10513,6 +10631,25 @@ snapshots:
es-errors@1.3.0: {}
es-iterator-helpers@1.2.2:
dependencies:
call-bind: 1.0.8
call-bound: 1.0.4
define-properties: 1.2.1
es-abstract: 1.24.1
es-errors: 1.3.0
es-set-tostringtag: 2.1.0
function-bind: 1.1.2
get-intrinsic: 1.3.0
globalthis: 1.0.4
gopd: 1.2.0
has-property-descriptors: 1.0.2
has-proto: 1.2.0
has-symbols: 1.1.0
internal-slot: 1.1.0
iterator.prototype: 1.1.5
safe-array-concat: 1.1.3
es-module-lexer@0.10.5: {}
es-module-lexer@1.7.0: {}
......@@ -10609,13 +10746,17 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-taro@4.1.9(@babel/core@7.28.6)(eslint@8.57.1)(typescript@5.9.3):
eslint-config-taro@4.1.9(@babel/core@7.28.6)(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint-plugin-vue@8.7.1(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3):
dependencies:
'@babel/eslint-parser': 7.28.6(@babel/core@7.28.6)(eslint@8.57.1)
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)
optionalDependencies:
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1)
eslint-plugin-vue: 8.7.1(eslint@8.57.1)
transitivePeerDependencies:
- '@babel/core'
- eslint-import-resolver-typescript
......@@ -10670,6 +10811,44 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-react-hooks@4.6.2(eslint@8.57.1):
dependencies:
eslint: 8.57.1
eslint-plugin-react@7.37.5(eslint@8.57.1):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
array.prototype.flatmap: 1.3.3
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.2.2
eslint: 8.57.1
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
minimatch: 3.1.2
object.entries: 1.1.9
object.fromentries: 2.0.8
object.values: 1.2.1
prop-types: 15.8.1
resolve: 2.0.0-next.5
semver: 6.3.1
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
eslint-plugin-vue@8.7.1(eslint@8.57.1):
dependencies:
eslint: 8.57.1
eslint-utils: 3.0.0(eslint@8.57.1)
natural-compare: 1.4.0
nth-check: 2.1.1
postcss-selector-parser: 6.1.2
semver: 7.7.3
vue-eslint-parser: 8.3.0(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-scope@5.1.1:
dependencies:
esrecurse: 4.3.0
......@@ -10680,6 +10859,11 @@ snapshots:
esrecurse: 4.3.0
estraverse: 5.3.0
eslint-utils@3.0.0(eslint@8.57.1):
dependencies:
eslint: 8.57.1
eslint-visitor-keys: 2.1.0
eslint-visitor-keys@2.1.0: {}
eslint-visitor-keys@3.4.3: {}
......@@ -11648,6 +11832,15 @@ snapshots:
has-to-string-tag-x: 1.4.1
is-object: 1.0.2
iterator.prototype@1.1.5:
dependencies:
define-data-property: 1.1.4
es-object-atoms: 1.1.1
get-intrinsic: 1.3.0
get-proto: 1.0.1
has-symbols: 1.1.0
set-function-name: 2.0.2
j-component@1.4.9:
dependencies:
expr-parser: 1.0.0
......@@ -11770,6 +11963,13 @@ snapshots:
dependencies:
object-assign: 4.1.1
jsx-ast-utils@3.3.5:
dependencies:
array-includes: 3.1.9
array.prototype.flat: 1.3.3
object.assign: 4.1.7
object.values: 1.2.1
keyv@3.0.0:
dependencies:
json-buffer: 3.0.0
......@@ -11997,6 +12197,10 @@ snapshots:
loglevel@1.9.2: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
lower-case@1.1.4: {}
lower-case@2.0.2:
......@@ -12246,6 +12450,13 @@ snapshots:
has-symbols: 1.1.0
object-keys: 1.1.1
object.entries@1.1.9:
dependencies:
call-bind: 1.0.8
call-bound: 1.0.4
define-properties: 1.2.1
es-object-atoms: 1.1.1
object.fromentries@2.0.8:
dependencies:
call-bind: 1.0.8
......@@ -13031,6 +13242,12 @@ snapshots:
promise-polyfill@7.1.2: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
property-expr@2.0.6: {}
proto-list@1.2.4: {}
......@@ -13109,6 +13326,8 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-is@16.13.1: {}
react-is@17.0.2: {}
react@19.2.4: {}
......@@ -13232,6 +13451,12 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
resolve@2.0.0-next.5:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
responselike@1.0.2:
dependencies:
lowercase-keys: 1.0.1
......@@ -13601,6 +13826,27 @@ snapshots:
string.fromcodepoint@0.2.1: {}
string.prototype.matchall@4.0.12:
dependencies:
call-bind: 1.0.8
call-bound: 1.0.4
define-properties: 1.2.1
es-abstract: 1.24.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
get-intrinsic: 1.3.0
gopd: 1.2.0
has-symbols: 1.1.0
internal-slot: 1.1.0
regexp.prototype.flags: 1.5.4
set-function-name: 2.0.2
side-channel: 1.1.0
string.prototype.repeat@1.0.0:
dependencies:
define-properties: 1.2.1
es-abstract: 1.24.1
string.prototype.trim@1.2.10:
dependencies:
call-bind: 1.0.8
......@@ -14074,6 +14320,32 @@ snapshots:
acorn: 8.15.0
acorn-walk: 8.3.4
vue-eslint-parser@8.3.0(eslint@8.57.1):
dependencies:
debug: 4.4.3
eslint: 8.57.1
eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3
espree: 9.6.1
esquery: 1.7.0
lodash: 4.17.23
semver: 7.7.3
transitivePeerDependencies:
- supports-color
vue-eslint-parser@9.4.3(eslint@8.57.1):
dependencies:
debug: 4.4.3
eslint: 8.57.1
eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3
espree: 9.6.1
esquery: 1.7.0
lodash: 4.17.23
semver: 7.7.3
transitivePeerDependencies:
- supports-color
vue-loader@17.4.2(@vue/compiler-sfc@3.5.27)(vue@3.5.27(typescript@5.9.3))(webpack@5.91.0(@swc/core@1.3.96)):
dependencies:
chalk: 4.1.2
......
/*
* @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>