| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360 |
- <template>
- <view class="webview-page">
- <!-- #ifdef APP-PLUS -->
- <!-- App:web-view 为原生层会挡住普通 view 的点击,须用 cover-view 盖在上面 -->
- <web-view class="webview" :key="webviewKey" :src="webviewSrc" @load="onEmbeddedWebviewLoad" />
- <cover-view class="header header--overlay" :style="headerStyle">
- <cover-view class="header-inner">
- <cover-view class="capsule">
- <cover-view
- class="capsule-btn"
- style="display: flex; align-items: center; justify-content: center; width: 46px; height: 28px"
- @tap="onCapsuleMoreMenu"
- >
- <!-- App:cover-view 内勿依赖 scoped;cover-image 宽高须内联 px -->
- <!-- App 端 cover-image 多数不支持 SVG,须用 PNG -->
- <cover-image
- style="width: 20px; height: 20px"
- src="/static/icons/capsule-more.png"
- mode="aspectFit"
- @tap="onCapsuleMoreMenu"
- />
- </cover-view>
- <cover-view class="capsule-divider" />
- <cover-view
- class="capsule-btn"
- style="display: flex; align-items: center; justify-content: center; width: 46px; height: 28px"
- @tap="onCapsuleClose"
- >
- <cover-image
- style="width: 20px; height: 20px"
- src="/static/icons/capsule-close.png"
- mode="aspectFit"
- @tap="onCapsuleClose"
- />
- </cover-view>
- </cover-view>
- </cover-view>
- </cover-view>
- <!-- #endif -->
- <!-- #ifndef APP-PLUS -->
- <view class="header" :style="headerStyle">
- <view class="header-inner">
- <view class="capsule">
- <view class="capsule-btn" @tap="onCapsuleMoreMenu">
- <image class="capsule-icon" src="/static/icons/capsule-more.svg" mode="aspectFit" />
- </view>
- <view class="capsule-divider" />
- <view class="capsule-btn" @tap="onCapsuleClose">
- <image class="capsule-icon" src="/static/icons/capsule-close.svg" mode="aspectFit" />
- </view>
- </view>
- </view>
- </view>
- <web-view class="webview" :key="webviewKey" :src="webviewSrc" />
- <!-- #endif -->
- </view>
- </template>
- <script setup>
- import { computed, nextTick, ref } from 'vue'
- import { onLoad, onReady, onShow } from '@dcloudio/uni-app'
- const decodedUrl = ref('')
- /** 传给 web-view 的地址;重新加载时会同步为原生子 webview 当前 getURL(含重定向后) */
- const webviewSrc = ref('')
- /** App 端每次 load 后刷新,供 getURL 暂不可用时回退 */
- const cachedCurrentUrl = ref('')
- /** 变更 key 以强制重建 web-view,实现整页重新加载 */
- const webviewKey = ref(0)
- function isUsableWebviewUrl(u) {
- const s = String(u || '').trim()
- if (!s) return false
- if (/^about:blank$/i.test(s)) return false
- return true
- }
- /** 用系统信息算顶栏 padding-top(部分端上 CSS env 不可靠) */
- function computeHeaderPaddingTopPx() {
- try {
- const si = uni.getSystemInfoSync()
- const insetTop =
- si.safeAreaInsets && typeof si.safeAreaInsets.top === 'number'
- ? si.safeAreaInsets.top
- : si.statusBarHeight || 0
- const r88 = uni.upx2px(88)
- const r24 = uni.upx2px(24)
- return Math.max(r88, r24 + insetTop)
- } catch (e) {
- return uni.upx2px(88)
- }
- }
- const headerPaddingTopPx = ref(computeHeaderPaddingTopPx())
- const headerStyle = computed(() => ({
- paddingTop: `${headerPaddingTopPx.value}px`
- }))
- onLoad((options) => {
- headerPaddingTopPx.value = computeHeaderPaddingTopPx()
- const rawUrl = options && options.url ? options.url : ''
- const initial = rawUrl ? decodeURIComponent(rawUrl) : ''
- decodedUrl.value = initial
- webviewSrc.value = initial
- cachedCurrentUrl.value = initial
- })
- /**
- * App 端 web-view 为原生组件,默认会铺满窗口,内嵌 H5 会与状态栏重叠。
- * 通过子 webview 的 setStyle 将内嵌区域限制在自定义顶栏下方。
- * 不可假定 children()[0] 为内嵌页,须遍历带 getURL 的子窗体。
- */
- // #ifdef APP-PLUS
- function urlsRoughlyMatch(a, b) {
- const strip = (s) =>
- String(s || '')
- .trim()
- .split('#')[0]
- .replace(/\/$/, '')
- const x = strip(a)
- const y = strip(b)
- if (!x || !y) return false
- if (x === y) return true
- try {
- return new URL(x).origin === new URL(y).origin
- } catch (e) {
- return x.includes(y) || y.includes(x)
- }
- }
- function findEmbeddedWebviewNative(currentWebview, preferredUrlHint) {
- try {
- const list = currentWebview.children && currentWebview.children()
- if (!list || !list.length) return null
- const candidates = []
- for (let i = 0; i < list.length; i++) {
- const w = list[i]
- if (w && typeof w.setStyle === 'function' && typeof w.getURL === 'function') {
- candidates.push(w)
- }
- }
- const hint = String(preferredUrlHint || '').trim()
- if (candidates.length === 1) return candidates[0]
- if (hint && candidates.length > 1) {
- for (let i = candidates.length - 1; i >= 0; i--) {
- try {
- const u = candidates[i].getURL()
- if (u && urlsRoughlyMatch(u, hint)) return candidates[i]
- } catch (e) {}
- }
- return candidates[candidates.length - 1]
- }
- if (candidates.length) return candidates[candidates.length - 1]
- for (let i = 0; i < list.length; i++) {
- const w = list[i]
- if (w && typeof w.setStyle === 'function') return w
- }
- } catch (e) {}
- return null
- }
- // #endif
- /** App 端返回子 webview 当前地址;其它端恒为空(无原生 getURL) */
- function getEmbeddedWebviewUrl() {
- // #ifdef APP-PLUS
- try {
- const pages = getCurrentPages()
- const page = pages[pages.length - 1]
- if (!page || typeof page.$getAppWebview !== 'function') return ''
- const wv = findEmbeddedWebviewNative(page.$getAppWebview(), webviewSrc.value)
- if (wv && typeof wv.getURL === 'function') {
- const u = wv.getURL()
- return u && String(u).trim() ? String(u).trim() : ''
- }
- } catch (e) {}
- // #endif
- return ''
- }
- function syncCachedUrlFromEmbeddedNative() {
- // #ifdef APP-PLUS
- const u = getEmbeddedWebviewUrl()
- if (isUsableWebviewUrl(u)) cachedCurrentUrl.value = u
- // #endif
- }
- function positionEmbeddedWebview(extraDelays) {
- // #ifdef APP-PLUS
- const pages = getCurrentPages()
- const page = pages[pages.length - 1]
- if (!page || typeof page.$getAppWebview !== 'function') return
- const currentWebview = page.$getAppWebview()
- const hint = webviewSrc.value
- const apply = () => {
- const wv = findEmbeddedWebviewNative(currentWebview, hint)
- if (!wv) return false
- uni.createSelectorQuery()
- .select('.header')
- .boundingClientRect((rect) => {
- if (!rect || rect.height == null) return
- const sys = uni.getSystemInfoSync()
- const winH = sys.windowHeight || sys.screenHeight
- const topPx = rect.height
- wv.setStyle({
- top: topPx,
- height: winH - topPx,
- left: 0,
- width: '100%'
- })
- })
- .exec()
- return true
- }
- const baseDelays = [0, 50, 100, 200, 400, 800, 1200, 2000]
- const delays =
- Array.isArray(extraDelays) && extraDelays.length
- ? [...new Set([...baseDelays, ...extraDelays])].sort((a, b) => a - b)
- : baseDelays
- delays.forEach((ms) => {
- setTimeout(() => {
- if (!apply()) {
- setTimeout(apply, 200)
- }
- }, ms)
- })
- // #endif
- }
- /** 内嵌页 load 完成后再缩窗;并同步子 webview 真实 URL(302 / 前端路由后 getURL 才是当前页) */
- function onEmbeddedWebviewLoad() {
- // #ifdef APP-PLUS
- syncCachedUrlFromEmbeddedNative()
- ;[200, 600, 1500].forEach((ms) => setTimeout(syncCachedUrlFromEmbeddedNative, ms))
- // #endif
- positionEmbeddedWebview()
- }
- /** 子 webview 因 key 重建后需多轮缩窗,否则易保持全屏被顶栏遮挡 */
- function schedulePositionAfterWebviewRemount() {
- // #ifdef APP-PLUS
- const extra = [
- 10, 30, 80, 150, 300, 500, 900, 1500, 2500, 3500, 5000
- ]
- nextTick(() => {
- positionEmbeddedWebview(extra)
- setTimeout(() => positionEmbeddedWebview(extra), 0)
- })
- // #endif
- }
- onReady(() => {
- positionEmbeddedWebview()
- })
- onShow(() => {
- // #ifdef APP-PLUS
- syncCachedUrlFromEmbeddedNative()
- // #endif
- // 从后台返回时子 webview 可能重排,再对齐一次
- positionEmbeddedWebview()
- })
- /** 更多:重新加载当前内嵌页;关闭:回到应用中心 Tab */
- function onCapsuleMoreMenu() {
- const url = decodedUrl.value
- console.log('[webview/capsule] reload menu', { url })
- uni.showActionSheet({
- itemList: ['重新加载'],
- success: (res) => {
- if (res.tapIndex !== 0) return
- reloadEmbeddedPage()
- }
- })
- }
- function reloadEmbeddedPage() {
- // #ifdef APP-PLUS
- syncCachedUrlFromEmbeddedNative()
- // #endif
- const target =
- getEmbeddedWebviewUrl() ||
- cachedCurrentUrl.value ||
- webviewSrc.value ||
- decodedUrl.value
- if (!isUsableWebviewUrl(target)) {
- uni.showToast({ title: '链接无效', icon: 'none' })
- return
- }
- const url = String(target).trim()
- webviewSrc.value = url
- cachedCurrentUrl.value = url
- webviewKey.value += 1
- schedulePositionAfterWebviewRemount()
- }
- function onCapsuleClose() {
- console.log('[webview/capsule] close', { url: decodedUrl.value })
- uni.switchTab({ url: '/pages/app-center/index' })
- }
- </script>
- <style scoped>
- .webview-page {
- display: flex;
- flex-direction: column;
- height: 100vh;
- background-color: #f8f8f8;
- }
- .header {
- padding: 0 24rpx 16rpx;
- padding-right: max(24rpx, constant(safe-area-inset-right));
- padding-right: max(24rpx, env(safe-area-inset-right));
- background-color: #ffffff;
- box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.06);
- }
- /* App:盖在原生 web-view 上,需脱离文档流避免布局错位 */
- .header--overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- z-index: 99999;
- box-sizing: border-box;
- }
- .header-inner {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- width: 100%;
- }
- .capsule {
- display: flex;
- align-items: center;
- flex-shrink: 0;
- height: 52rpx;
- padding: 0 2rpx;
- background: rgba(0, 0, 0, 0.06);
- border: 1rpx solid rgba(0, 0, 0, 0.08);
- border-radius: 26rpx;
- }
- .capsule-btn {
- width: 52rpx;
- height: 52rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .capsule-icon {
- width: 30rpx;
- height: 30rpx;
- }
- .capsule-divider {
- width: 1rpx;
- height: 26rpx;
- background: rgba(0, 0, 0, 0.12);
- }
- .webview {
- flex: 1;
- }
- </style>
|