index.vue 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. <template>
  2. <view class="webview-page">
  3. <!-- #ifdef APP-PLUS -->
  4. <!-- App:web-view 为原生层会挡住普通 view 的点击,须用 cover-view 盖在上面 -->
  5. <web-view class="webview" :key="webviewKey" :src="webviewSrc" @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" :key="webviewKey" :src="webviewSrc" />
  55. <!-- #endif -->
  56. </view>
  57. </template>
  58. <script setup>
  59. import { computed, nextTick, ref } from 'vue'
  60. import { onLoad, onReady, onShow } from '@dcloudio/uni-app'
  61. const decodedUrl = ref('')
  62. /** 传给 web-view 的地址;重新加载时会同步为原生子 webview 当前 getURL(含重定向后) */
  63. const webviewSrc = ref('')
  64. /** App 端每次 load 后刷新,供 getURL 暂不可用时回退 */
  65. const cachedCurrentUrl = ref('')
  66. /** 变更 key 以强制重建 web-view,实现整页重新加载 */
  67. const webviewKey = ref(0)
  68. function isUsableWebviewUrl(u) {
  69. const s = String(u || '').trim()
  70. if (!s) return false
  71. if (/^about:blank$/i.test(s)) return false
  72. return true
  73. }
  74. /** 用系统信息算顶栏 padding-top(部分端上 CSS env 不可靠) */
  75. function computeHeaderPaddingTopPx() {
  76. try {
  77. const si = uni.getSystemInfoSync()
  78. const insetTop =
  79. si.safeAreaInsets && typeof si.safeAreaInsets.top === 'number'
  80. ? si.safeAreaInsets.top
  81. : si.statusBarHeight || 0
  82. const r88 = uni.upx2px(88)
  83. const r24 = uni.upx2px(24)
  84. return Math.max(r88, r24 + insetTop)
  85. } catch (e) {
  86. return uni.upx2px(88)
  87. }
  88. }
  89. const headerPaddingTopPx = ref(computeHeaderPaddingTopPx())
  90. const headerStyle = computed(() => ({
  91. paddingTop: `${headerPaddingTopPx.value}px`
  92. }))
  93. onLoad((options) => {
  94. headerPaddingTopPx.value = computeHeaderPaddingTopPx()
  95. const rawUrl = options && options.url ? options.url : ''
  96. const initial = rawUrl ? decodeURIComponent(rawUrl) : ''
  97. decodedUrl.value = initial
  98. webviewSrc.value = initial
  99. cachedCurrentUrl.value = initial
  100. })
  101. /**
  102. * App 端 web-view 为原生组件,默认会铺满窗口,内嵌 H5 会与状态栏重叠。
  103. * 通过子 webview 的 setStyle 将内嵌区域限制在自定义顶栏下方。
  104. * 不可假定 children()[0] 为内嵌页,须遍历带 getURL 的子窗体。
  105. */
  106. // #ifdef APP-PLUS
  107. function urlsRoughlyMatch(a, b) {
  108. const strip = (s) =>
  109. String(s || '')
  110. .trim()
  111. .split('#')[0]
  112. .replace(/\/$/, '')
  113. const x = strip(a)
  114. const y = strip(b)
  115. if (!x || !y) return false
  116. if (x === y) return true
  117. try {
  118. return new URL(x).origin === new URL(y).origin
  119. } catch (e) {
  120. return x.includes(y) || y.includes(x)
  121. }
  122. }
  123. function findEmbeddedWebviewNative(currentWebview, preferredUrlHint) {
  124. try {
  125. const list = currentWebview.children && currentWebview.children()
  126. if (!list || !list.length) return null
  127. const candidates = []
  128. for (let i = 0; i < list.length; i++) {
  129. const w = list[i]
  130. if (w && typeof w.setStyle === 'function' && typeof w.getURL === 'function') {
  131. candidates.push(w)
  132. }
  133. }
  134. const hint = String(preferredUrlHint || '').trim()
  135. if (candidates.length === 1) return candidates[0]
  136. if (hint && candidates.length > 1) {
  137. for (let i = candidates.length - 1; i >= 0; i--) {
  138. try {
  139. const u = candidates[i].getURL()
  140. if (u && urlsRoughlyMatch(u, hint)) return candidates[i]
  141. } catch (e) {}
  142. }
  143. return candidates[candidates.length - 1]
  144. }
  145. if (candidates.length) return candidates[candidates.length - 1]
  146. for (let i = 0; i < list.length; i++) {
  147. const w = list[i]
  148. if (w && typeof w.setStyle === 'function') return w
  149. }
  150. } catch (e) {}
  151. return null
  152. }
  153. // #endif
  154. /** App 端返回子 webview 当前地址;其它端恒为空(无原生 getURL) */
  155. function getEmbeddedWebviewUrl() {
  156. // #ifdef APP-PLUS
  157. try {
  158. const pages = getCurrentPages()
  159. const page = pages[pages.length - 1]
  160. if (!page || typeof page.$getAppWebview !== 'function') return ''
  161. const wv = findEmbeddedWebviewNative(page.$getAppWebview(), webviewSrc.value)
  162. if (wv && typeof wv.getURL === 'function') {
  163. const u = wv.getURL()
  164. return u && String(u).trim() ? String(u).trim() : ''
  165. }
  166. } catch (e) {}
  167. // #endif
  168. return ''
  169. }
  170. function syncCachedUrlFromEmbeddedNative() {
  171. // #ifdef APP-PLUS
  172. const u = getEmbeddedWebviewUrl()
  173. if (isUsableWebviewUrl(u)) cachedCurrentUrl.value = u
  174. // #endif
  175. }
  176. function positionEmbeddedWebview(extraDelays) {
  177. // #ifdef APP-PLUS
  178. const pages = getCurrentPages()
  179. const page = pages[pages.length - 1]
  180. if (!page || typeof page.$getAppWebview !== 'function') return
  181. const currentWebview = page.$getAppWebview()
  182. const hint = webviewSrc.value
  183. const apply = () => {
  184. const wv = findEmbeddedWebviewNative(currentWebview, hint)
  185. if (!wv) return false
  186. uni.createSelectorQuery()
  187. .select('.header')
  188. .boundingClientRect((rect) => {
  189. if (!rect || rect.height == null) return
  190. const sys = uni.getSystemInfoSync()
  191. const winH = sys.windowHeight || sys.screenHeight
  192. const topPx = rect.height
  193. wv.setStyle({
  194. top: topPx,
  195. height: winH - topPx,
  196. left: 0,
  197. width: '100%'
  198. })
  199. })
  200. .exec()
  201. return true
  202. }
  203. const baseDelays = [0, 50, 100, 200, 400, 800, 1200, 2000]
  204. const delays =
  205. Array.isArray(extraDelays) && extraDelays.length
  206. ? [...new Set([...baseDelays, ...extraDelays])].sort((a, b) => a - b)
  207. : baseDelays
  208. delays.forEach((ms) => {
  209. setTimeout(() => {
  210. if (!apply()) {
  211. setTimeout(apply, 200)
  212. }
  213. }, ms)
  214. })
  215. // #endif
  216. }
  217. /** 内嵌页 load 完成后再缩窗;并同步子 webview 真实 URL(302 / 前端路由后 getURL 才是当前页) */
  218. function onEmbeddedWebviewLoad() {
  219. // #ifdef APP-PLUS
  220. syncCachedUrlFromEmbeddedNative()
  221. ;[200, 600, 1500].forEach((ms) => setTimeout(syncCachedUrlFromEmbeddedNative, ms))
  222. // #endif
  223. positionEmbeddedWebview()
  224. }
  225. /** 子 webview 因 key 重建后需多轮缩窗,否则易保持全屏被顶栏遮挡 */
  226. function schedulePositionAfterWebviewRemount() {
  227. // #ifdef APP-PLUS
  228. const extra = [
  229. 10, 30, 80, 150, 300, 500, 900, 1500, 2500, 3500, 5000
  230. ]
  231. nextTick(() => {
  232. positionEmbeddedWebview(extra)
  233. setTimeout(() => positionEmbeddedWebview(extra), 0)
  234. })
  235. // #endif
  236. }
  237. onReady(() => {
  238. positionEmbeddedWebview()
  239. })
  240. onShow(() => {
  241. // #ifdef APP-PLUS
  242. syncCachedUrlFromEmbeddedNative()
  243. // #endif
  244. // 从后台返回时子 webview 可能重排,再对齐一次
  245. positionEmbeddedWebview()
  246. })
  247. /** 更多:重新加载当前内嵌页;关闭:回到应用中心 Tab */
  248. function onCapsuleMoreMenu() {
  249. const url = decodedUrl.value
  250. console.log('[webview/capsule] reload menu', { url })
  251. uni.showActionSheet({
  252. itemList: ['重新加载'],
  253. success: (res) => {
  254. if (res.tapIndex !== 0) return
  255. reloadEmbeddedPage()
  256. }
  257. })
  258. }
  259. function reloadEmbeddedPage() {
  260. // #ifdef APP-PLUS
  261. syncCachedUrlFromEmbeddedNative()
  262. // #endif
  263. const target =
  264. getEmbeddedWebviewUrl() ||
  265. cachedCurrentUrl.value ||
  266. webviewSrc.value ||
  267. decodedUrl.value
  268. if (!isUsableWebviewUrl(target)) {
  269. uni.showToast({ title: '链接无效', icon: 'none' })
  270. return
  271. }
  272. const url = String(target).trim()
  273. webviewSrc.value = url
  274. cachedCurrentUrl.value = url
  275. webviewKey.value += 1
  276. schedulePositionAfterWebviewRemount()
  277. }
  278. function onCapsuleClose() {
  279. console.log('[webview/capsule] close', { url: decodedUrl.value })
  280. uni.switchTab({ url: '/pages/app-center/index' })
  281. }
  282. </script>
  283. <style scoped>
  284. .webview-page {
  285. display: flex;
  286. flex-direction: column;
  287. height: 100vh;
  288. background-color: #f8f8f8;
  289. }
  290. .header {
  291. padding: 0 24rpx 16rpx;
  292. padding-right: max(24rpx, constant(safe-area-inset-right));
  293. padding-right: max(24rpx, env(safe-area-inset-right));
  294. background-color: #ffffff;
  295. box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.06);
  296. }
  297. /* App:盖在原生 web-view 上,需脱离文档流避免布局错位 */
  298. .header--overlay {
  299. position: fixed;
  300. top: 0;
  301. left: 0;
  302. right: 0;
  303. z-index: 99999;
  304. box-sizing: border-box;
  305. }
  306. .header-inner {
  307. display: flex;
  308. align-items: center;
  309. justify-content: flex-end;
  310. width: 100%;
  311. }
  312. .capsule {
  313. display: flex;
  314. align-items: center;
  315. flex-shrink: 0;
  316. height: 52rpx;
  317. padding: 0 2rpx;
  318. background: rgba(0, 0, 0, 0.06);
  319. border: 1rpx solid rgba(0, 0, 0, 0.08);
  320. border-radius: 26rpx;
  321. }
  322. .capsule-btn {
  323. width: 52rpx;
  324. height: 52rpx;
  325. display: flex;
  326. align-items: center;
  327. justify-content: center;
  328. }
  329. .capsule-icon {
  330. width: 30rpx;
  331. height: 30rpx;
  332. }
  333. .capsule-divider {
  334. width: 1rpx;
  335. height: 26rpx;
  336. background: rgba(0, 0, 0, 0.12);
  337. }
  338. .webview {
  339. flex: 1;
  340. }
  341. </style>