index.vue 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. <template>
  2. <view class="webview-page">
  3. <!-- #ifdef APP-PLUS -->
  4. <!-- App:web-view 为原生层会挡住普通 view 的点击,须用 cover-view 盖在上面 -->
  5. <web-view class="webview" :src="decodedUrl" @load="onEmbeddedWebviewLoad" />
  6. <cover-view class="header header--overlay" :style="headerStyle">
  7. <cover-view class="header-inner">
  8. <cover-view class="capsule">
  9. <cover-view
  10. class="capsule-btn"
  11. style="display: flex; align-items: center; justify-content: center; width: 46px; height: 28px"
  12. @tap="onCapsuleMoreMenu"
  13. >
  14. <!-- App:cover-view 内勿依赖 scoped;cover-image 宽高须内联 px -->
  15. <!-- App 端 cover-image 多数不支持 SVG,须用 PNG -->
  16. <cover-image
  17. style="width: 20px; height: 20px"
  18. src="/static/icons/capsule-more.png"
  19. mode="aspectFit"
  20. @tap="onCapsuleMoreMenu"
  21. />
  22. </cover-view>
  23. <cover-view class="capsule-divider" />
  24. <cover-view
  25. class="capsule-btn"
  26. style="display: flex; align-items: center; justify-content: center; width: 46px; height: 28px"
  27. @tap="onCapsuleClose"
  28. >
  29. <cover-image
  30. style="width: 20px; height: 20px"
  31. src="/static/icons/capsule-close.png"
  32. mode="aspectFit"
  33. @tap="onCapsuleClose"
  34. />
  35. </cover-view>
  36. </cover-view>
  37. </cover-view>
  38. </cover-view>
  39. <!-- #endif -->
  40. <!-- #ifndef APP-PLUS -->
  41. <view class="header" :style="headerStyle">
  42. <view class="header-inner">
  43. <view class="capsule">
  44. <view class="capsule-btn" @tap="onCapsuleMoreMenu">
  45. <image class="capsule-icon" src="/static/icons/capsule-more.svg" mode="aspectFit" />
  46. </view>
  47. <view class="capsule-divider" />
  48. <view class="capsule-btn" @tap="onCapsuleClose">
  49. <image class="capsule-icon" src="/static/icons/capsule-close.svg" mode="aspectFit" />
  50. </view>
  51. </view>
  52. </view>
  53. </view>
  54. <web-view class="webview" :src="decodedUrl" />
  55. <!-- #endif -->
  56. </view>
  57. </template>
  58. <script setup>
  59. import { computed, ref } from 'vue'
  60. import { onLoad, onReady, onShow } from '@dcloudio/uni-app'
  61. const decodedUrl = ref('')
  62. /** 用系统信息算顶栏 padding-top(部分端上 CSS env 不可靠) */
  63. function computeHeaderPaddingTopPx() {
  64. try {
  65. const si = uni.getSystemInfoSync()
  66. const insetTop =
  67. si.safeAreaInsets && typeof si.safeAreaInsets.top === 'number'
  68. ? si.safeAreaInsets.top
  69. : si.statusBarHeight || 0
  70. const r88 = uni.upx2px(88)
  71. const r24 = uni.upx2px(24)
  72. return Math.max(r88, r24 + insetTop)
  73. } catch (e) {
  74. return uni.upx2px(88)
  75. }
  76. }
  77. const headerPaddingTopPx = ref(computeHeaderPaddingTopPx())
  78. const headerStyle = computed(() => ({
  79. paddingTop: `${headerPaddingTopPx.value}px`
  80. }))
  81. onLoad((options) => {
  82. headerPaddingTopPx.value = computeHeaderPaddingTopPx()
  83. const rawUrl = options && options.url ? options.url : ''
  84. decodedUrl.value = rawUrl ? decodeURIComponent(rawUrl) : ''
  85. })
  86. /**
  87. * App 端 web-view 为原生组件,默认会铺满窗口,内嵌 H5 会与状态栏重叠。
  88. * 通过子 webview 的 setStyle 将内嵌区域限制在自定义顶栏下方。
  89. * 不可假定 children()[0] 为内嵌页,须遍历带 getURL 的子窗体。
  90. */
  91. // #ifdef APP-PLUS
  92. function findEmbeddedWebviewNative(currentWebview) {
  93. try {
  94. const list = currentWebview.children && currentWebview.children()
  95. if (!list || !list.length) return null
  96. for (let i = 0; i < list.length; i++) {
  97. const w = list[i]
  98. if (w && typeof w.setStyle === 'function' && typeof w.getURL === 'function') {
  99. return w
  100. }
  101. }
  102. for (let i = 0; i < list.length; i++) {
  103. const w = list[i]
  104. if (w && typeof w.setStyle === 'function') return w
  105. }
  106. } catch (e) {}
  107. return null
  108. }
  109. // #endif
  110. function positionEmbeddedWebview() {
  111. // #ifdef APP-PLUS
  112. const pages = getCurrentPages()
  113. const page = pages[pages.length - 1]
  114. if (!page || typeof page.$getAppWebview !== 'function') return
  115. const currentWebview = page.$getAppWebview()
  116. const apply = () => {
  117. const wv = findEmbeddedWebviewNative(currentWebview)
  118. if (!wv) return false
  119. uni.createSelectorQuery()
  120. .select('.header')
  121. .boundingClientRect((rect) => {
  122. if (!rect || rect.height == null) return
  123. const sys = uni.getSystemInfoSync()
  124. const winH = sys.windowHeight || sys.screenHeight
  125. const topPx = rect.height
  126. wv.setStyle({
  127. top: topPx,
  128. height: winH - topPx,
  129. left: 0,
  130. width: '100%'
  131. })
  132. })
  133. .exec()
  134. return true
  135. }
  136. const delays = [0, 50, 100, 200, 400, 800, 1200, 2000]
  137. delays.forEach((ms) => {
  138. setTimeout(() => {
  139. if (!apply()) {
  140. setTimeout(apply, 200)
  141. }
  142. }, ms)
  143. })
  144. // #endif
  145. }
  146. /** 内嵌页 load 完成后再缩窗,避免子 webview 尚未就绪时仍全屏挡在胶囊下 */
  147. function onEmbeddedWebviewLoad() {
  148. positionEmbeddedWebview()
  149. }
  150. onReady(() => {
  151. positionEmbeddedWebview()
  152. })
  153. onShow(() => {
  154. // 从后台返回时子 webview 可能重排,再对齐一次
  155. positionEmbeddedWebview()
  156. })
  157. /** 更多:底部菜单(当前含「用系统浏览器打开」);关闭:清栈并回到应用中心 Tab */
  158. function onCapsuleMoreMenu() {
  159. const url = decodedUrl.value
  160. console.log('[webview/capsule] more', { url })
  161. uni.showActionSheet({
  162. itemList: ['用系统浏览器打开'],
  163. success: (res) => {
  164. if (res.tapIndex !== 0) return
  165. openUrlInSystemBrowser(url)
  166. }
  167. })
  168. }
  169. function openUrlInSystemBrowser(url) {
  170. const u = String(url || '').trim()
  171. if (!u) {
  172. uni.showToast({ title: '链接无效', icon: 'none' })
  173. return
  174. }
  175. // #ifdef APP-PLUS
  176. try {
  177. plus.runtime.openURL(u)
  178. } catch (e) {
  179. uni.showToast({ title: '无法打开', icon: 'none' })
  180. }
  181. // #endif
  182. // #ifdef H5
  183. if (typeof window !== 'undefined' && window.open) {
  184. window.open(u, '_blank')
  185. }
  186. // #endif
  187. // #ifndef APP-PLUS || H5
  188. uni.showToast({ title: '当前环境不支持', icon: 'none' })
  189. // #endif
  190. }
  191. function onCapsuleClose() {
  192. console.log('[webview/capsule] close', { url: decodedUrl.value })
  193. uni.switchTab({ url: '/pages/app-center/index' })
  194. }
  195. </script>
  196. <style scoped>
  197. .webview-page {
  198. display: flex;
  199. flex-direction: column;
  200. height: 100vh;
  201. background-color: #f8f8f8;
  202. }
  203. .header {
  204. padding: 0 24rpx 16rpx;
  205. padding-right: max(24rpx, constant(safe-area-inset-right));
  206. padding-right: max(24rpx, env(safe-area-inset-right));
  207. background-color: #ffffff;
  208. box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.06);
  209. }
  210. /* App:盖在原生 web-view 上,需脱离文档流避免布局错位 */
  211. .header--overlay {
  212. position: fixed;
  213. top: 0;
  214. left: 0;
  215. right: 0;
  216. z-index: 99999;
  217. box-sizing: border-box;
  218. }
  219. .header-inner {
  220. display: flex;
  221. align-items: center;
  222. justify-content: flex-end;
  223. width: 100%;
  224. }
  225. .capsule {
  226. display: flex;
  227. align-items: center;
  228. flex-shrink: 0;
  229. height: 52rpx;
  230. padding: 0 2rpx;
  231. background: rgba(0, 0, 0, 0.06);
  232. border: 1rpx solid rgba(0, 0, 0, 0.08);
  233. border-radius: 26rpx;
  234. }
  235. .capsule-btn {
  236. width: 52rpx;
  237. height: 52rpx;
  238. display: flex;
  239. align-items: center;
  240. justify-content: center;
  241. }
  242. .capsule-icon {
  243. width: 30rpx;
  244. height: 30rpx;
  245. }
  246. .capsule-divider {
  247. width: 1rpx;
  248. height: 26rpx;
  249. background: rgba(0, 0, 0, 0.12);
  250. }
  251. .webview {
  252. flex: 1;
  253. }
  254. </style>