wg-h5-preview-file.vue 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. <template>
  2. <view class="wrapper" v-if="webviewUrl || value">
  3. <view class="close" @click="close">
  4. <uni-icons v-if="!$slots.close" class="close-icon" type="close" size="40" style="color: #999999;"></uni-icons>
  5. </view>
  6. <view class="wg-h5-preview-file-container" :style="containerStyle">
  7. <view class="webview">
  8. <!-- #ifdef H5 -->
  9. <iframe
  10. class="wg-h5-preview-file-iframe"
  11. :webview-styles="webviewStyles"
  12. :src="iframeUrl" />
  13. <!-- #endif -->
  14. </view>
  15. </view>
  16. </view>
  17. </template>
  18. <script>
  19. const urlMap = {
  20. word: "/static/hybrid/word.html?file=",
  21. pdf: "/static/hybrid/pdf/web/viewer.html?file=",
  22. excel: "/static/hybrid/excel.html?file=",
  23. txt: "/static/hybrid/txt.html?file=",
  24. pptx: "/static/hybrid/pptx.html?file=",
  25. };
  26. export default {
  27. name: "WgH5PreviewFile",
  28. props: {
  29. value: {
  30. type: String,
  31. required: true,
  32. },
  33. type: {
  34. type: String,
  35. required: true,
  36. validator(v) {
  37. return ["excel", "pdf", "word", "txt", "pptx"].includes(v);
  38. },
  39. },
  40. },
  41. computed: {
  42. iframeUrl() {
  43. return (
  44. this.webviewUrl || this.buildPreviewUrl(this.type, this.value)
  45. );
  46. },
  47. containerStyle() {
  48. // 动态设置容器的实际可视高度,避免 100vh 在移动端地址栏收起/展开导致的遮挡问题
  49. const style = {
  50. height:
  51. this.containerHeight > 0
  52. ? `${this.containerHeight}px`
  53. : "100vh",
  54. };
  55. // 当不支持 CSS env/constant(safe-area-inset-bottom) 时,用内联 padding 兜底
  56. if (!this.cssSafeAreaSupported && this.safeBottom > 0) {
  57. style.paddingBottom = `${this.safeBottom}px`;
  58. }
  59. return style;
  60. },
  61. },
  62. data() {
  63. return {
  64. safeBottom: 0,
  65. webviewUrl: null,
  66. webviewStyles: {
  67. zIndex: "9999",
  68. },
  69. // 实际可视区域高度(px)
  70. containerHeight: 0,
  71. // 是否支持 CSS 安全区变量
  72. cssSafeAreaSupported: false,
  73. };
  74. },
  75. methods: {
  76. preview({ url, type }) {
  77. this.webviewUrl = this.buildPreviewUrl(type, url);
  78. },
  79. buildPreviewUrl(fileType, url = "") {
  80. const relativePath = urlMap[fileType] || urlMap.word;
  81. const sanitizedPath = relativePath.replace(/^\//, "");
  82. const basePath = this.getPreviewBasePath();
  83. return `${basePath}${sanitizedPath}${encodeURIComponent(
  84. url || this.value
  85. )}&safeBottom=${this.safeBottom}`;
  86. },
  87. getPreviewBasePath() {
  88. if (typeof window !== "undefined") {
  89. const configBase =
  90. (window.__uniConfig &&
  91. window.__uniConfig.router &&
  92. window.__uniConfig.router.base) ||
  93. (window.__uniConfig && window.__uniConfig.publicPath);
  94. if (configBase && configBase !== "./" && configBase !== ".") {
  95. return configBase.endsWith("/")
  96. ? configBase
  97. : `${configBase}/`;
  98. }
  99. const pathname = window.location.pathname || "/";
  100. if (pathname === "/") {
  101. return "/";
  102. }
  103. if (pathname.endsWith("/")) {
  104. return pathname;
  105. }
  106. const lastSlashIndex = pathname.lastIndexOf("/");
  107. const lastSegment = pathname.slice(lastSlashIndex + 1);
  108. const looksLikeFile = lastSegment.includes(".");
  109. if (looksLikeFile) {
  110. return pathname.replace(/\/[^/]*$/, "/");
  111. }
  112. return `${pathname}/`;
  113. }
  114. return "/";
  115. },
  116. updateViewportHeight() {
  117. if (typeof window === "undefined") {
  118. this.containerHeight = 0;
  119. return;
  120. }
  121. let height = 0;
  122. if (
  123. window.visualViewport &&
  124. typeof window.visualViewport.height === "number"
  125. ) {
  126. height = Math.round(window.visualViewport.height);
  127. } else {
  128. height = window.innerHeight;
  129. }
  130. // 计算安全区底部像素,用于 iframe 参数传递与不支持 env 的兜底
  131. this.safeBottom = this.getSafeAreaInsetBottomPx();
  132. this.containerHeight = height;
  133. },
  134. addViewportListeners() {
  135. if (typeof window === "undefined") return;
  136. // 绑定一次,便于移除
  137. this._boundUpdateViewportHeight =
  138. this._boundUpdateViewportHeight ||
  139. this.updateViewportHeight.bind(this);
  140. window.addEventListener("resize", this._boundUpdateViewportHeight, {
  141. passive: true,
  142. });
  143. window.addEventListener(
  144. "orientationchange",
  145. this._boundUpdateViewportHeight,
  146. { passive: true }
  147. );
  148. if (window.visualViewport) {
  149. window.visualViewport.addEventListener(
  150. "resize",
  151. this._boundUpdateViewportHeight,
  152. { passive: true }
  153. );
  154. }
  155. },
  156. removeViewportListeners() {
  157. if (
  158. typeof window === "undefined" ||
  159. !this._boundUpdateViewportHeight
  160. )
  161. return;
  162. window.removeEventListener(
  163. "resize",
  164. this._boundUpdateViewportHeight
  165. );
  166. window.removeEventListener(
  167. "orientationchange",
  168. this._boundUpdateViewportHeight
  169. );
  170. if (window.visualViewport) {
  171. window.visualViewport.removeEventListener(
  172. "resize",
  173. this._boundUpdateViewportHeight
  174. );
  175. }
  176. },
  177. checkCssSafeAreaSupport() {
  178. if (typeof window === "undefined" || !window.CSS || !CSS.supports)
  179. return false;
  180. // 支持 env 或老版本 constant
  181. return (
  182. CSS.supports("padding-bottom: env(safe-area-inset-bottom)") ||
  183. CSS.supports("padding-bottom: constant(safe-area-inset-bottom)")
  184. );
  185. },
  186. getSafeAreaInsetBottomPx() {
  187. if (typeof window === "undefined" || !document || !document.body)
  188. return 0;
  189. let value = 0;
  190. try {
  191. const probe = document.createElement("div");
  192. probe.style.position = "absolute";
  193. probe.style.visibility = "hidden";
  194. probe.style.pointerEvents = "none";
  195. probe.style.paddingBottom = "env(safe-area-inset-bottom)";
  196. document.body.appendChild(probe);
  197. const v1 = parseFloat(
  198. window.getComputedStyle(probe).paddingBottom || "0"
  199. );
  200. document.body.removeChild(probe);
  201. if (v1 > 0) return Math.round(v1);
  202. // 尝试旧语法 constant()
  203. const probe2 = document.createElement("div");
  204. probe2.style.position = "absolute";
  205. probe2.style.visibility = "hidden";
  206. probe2.style.pointerEvents = "none";
  207. probe2.style.paddingBottom = "constant(safe-area-inset-bottom)";
  208. document.body.appendChild(probe2);
  209. const v2 = parseFloat(
  210. window.getComputedStyle(probe2).paddingBottom || "0"
  211. );
  212. document.body.removeChild(probe2);
  213. value = v2 > 0 ? Math.round(v2) : 0;
  214. } catch (e) {
  215. value = 0;
  216. }
  217. return value;
  218. },
  219. close() {
  220. this.webviewUrl = null;
  221. this.$emit('CloseDialog');
  222. // uni.navigateBack();
  223. },
  224. },
  225. mounted() {
  226. this.safeBottom = 0;
  227. this.cssSafeAreaSupported = this.checkCssSafeAreaSupport();
  228. this.updateViewportHeight();
  229. this.addViewportListeners();
  230. },
  231. beforeDestroy() {
  232. this.removeViewportListeners();
  233. },
  234. };
  235. </script>
  236. <style lang="scss">
  237. .wrapper {
  238. position: relative;
  239. .close {
  240. position: fixed;
  241. top: 44px;
  242. right: 20px;
  243. z-index: 9999;
  244. }
  245. .close-icon {
  246. width: 46px;
  247. height: 46px;
  248. }
  249. }
  250. iframe {
  251. z-index: 9999 !important;
  252. top: 0 !important;
  253. }
  254. .wg-h5-preview-file-iframe {
  255. width: 100vw;
  256. height: 100% !important;
  257. }
  258. .wg-h5-preview-file-container {
  259. position: fixed;
  260. left: 0px;
  261. bottom: 0;
  262. overflow: hidden;
  263. z-index: 998;
  264. width: 100vw;
  265. height: 100%;
  266. // iOS 刘海屏底部安全区
  267. padding-bottom: constant(safe-area-inset-bottom);
  268. padding-bottom: env(safe-area-inset-bottom);
  269. background-color: #fff;
  270. }
  271. .webview {
  272. height: 100% !important;
  273. }
  274. </style>