Files
shanxia2.0/miniprogram/pages/common/mp-webview.vue
T
2026-10-22 16:24:46 +08:00

296 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="mp-wv-page">
<view v-if="errorText" class="mp-wv-error">
<text class="mp-wv-error__title">{{ errorText }}</text>
</view>
<!-- 等 onReady 后再挂载 web-view,避免跳转尚未完成时嵌入 H5 导致 navigateTo:fail timeout -->
<web-view v-else-if="src" :src="src" @message="onWebViewMessage" />
<view v-else class="mp-wv-error">加载中…</view>
</view>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { onLoad, onReady, onShow } from '@dcloudio/uni-app'
import { AUTH_API, PAGE_PATHS, resolveApiBase, resolveWebOrigin } from '@/config/api'
import { IS_TEST_LOCAL_H5 } from '@/config/env'
import { request, parseResponseJson } from '@/utils/request'
import { useUserStore } from '@/stores/user'
/** 热更新错配时:本地签发 ticket、生产打开 H5 → 401 */
function assertTicketEnvAligned() {
// 唯一合法混合:APP_ENV=testLocalH5(测试 API + 本机 H5),由 env 预设保证地址配对
if (IS_TEST_LOCAL_H5) return
const api = resolveApiBase()
const web = resolveWebOrigin()
const apiLocal = /127\.0\.0\.1|localhost/.test(api)
const webLocal = /127\.0\.0\.1|localhost/.test(web)
const apiProd = /shanxiachem\.com/.test(api)
const webProd = /shanxiachem\.com/.test(web)
if (apiLocal !== webLocal || apiProd !== webProd) {
throw new Error(
`环境错配:API=${api},Web=${web}。请改 config/env.js 的 APP_ENV,并重新「运行到小程序」完整编译。`
)
}
}
const MP_WEBVIEW_GO_WORKBENCH = 'MP_WEBVIEW_GO_WORKBENCH'
const MP_WEBVIEW_COPY_TEXT = 'MP_WEBVIEW_COPY_TEXT'
const src = ref('')
const errorText = ref('')
const pageReady = ref(false)
/** ticket 就绪后的完整 H5 URL,等 pageReady 再赋给 src */
let pendingSrc = ''
function normalizePath(raw) {
let path = String(raw || '').trim()
for (let i = 0; i < 3; i++) {
if (!path || path.startsWith('/m/')) break
try {
const next = decodeURIComponent(path)
if (next === path) break
path = next.trim()
} catch {
break
}
}
return path
}
function isAllowedPath(path) {
return typeof path === 'string' && path.startsWith('/m/')
}
function goWorkbench() {
uni.reLaunch({ url: PAGE_PATHS.workbench })
}
function tryMountWebView() {
if (!pageReady.value || !pendingSrc || errorText.value) return
src.value = pendingSrc
pendingSrc = ''
}
function fallbackExecCommandCopy(text) {
// #ifdef H5
try {
if (typeof document === 'undefined') return false
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return ok
} catch {
return false
}
// #endif
// #ifndef H5
return false
// #endif
}
async function copyTextOnHost(text) {
const value = String(text || '')
if (!value) return false
// #ifdef H5
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
return true
}
} catch {
/* fall through */
}
if (fallbackExecCommandCopy(value)) {
return true
}
// #endif
return await new Promise((resolve) => {
uni.setClipboardData({
data: value,
success: () => resolve(true),
fail: () => resolve(false)
})
})
}
function handleHostPayload(data) {
if (!data || typeof data !== 'object') return
if (data.type === MP_WEBVIEW_GO_WORKBENCH) {
goWorkbench()
return
}
if (data.type === MP_WEBVIEW_COPY_TEXT) {
void copyTextOnHost(data.text)
}
}
function onWebViewMessage(event) {
const list = event && event.detail && event.detail.data
if (!Array.isArray(list)) return
for (let i = list.length - 1; i >= 0; i--) {
handleHostPayload(list[i])
}
}
function onHostMessage(event) {
handleHostPayload(event && event.data)
}
async function fetchMpWebviewTicket(maxAttempts = 3) {
let lastMessage = '无法打开内嵌页'
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await request({
url: AUTH_API.MP_WEBVIEW_TICKET,
method: 'POST'
})
const result = parseResponseJson(res)
const code = result && result.code
const message = (result && result.message) || ''
const ticket = code === 200 ? result.data?.ticket : ''
if (ticket) return ticket
lastMessage = message || lastMessage
if (Number(code) === 503 && attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, 300 * attempt))
continue
}
break
} catch (error) {
const errMsg = (error && error.message) || String(error)
lastMessage = errMsg || lastMessage
if (/timeout|fail|网络/i.test(String(lastMessage)) && attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, 300 * attempt))
continue
}
throw error
}
}
throw new Error(lastMessage)
}
function buildMpWebviewUrl(origin, path, ticket, returnOrigin) {
const qsParts = [`mpTicket=${encodeURIComponent(ticket)}`]
if (returnOrigin) {
qsParts.push(`mpReturnOrigin=${encodeURIComponent(returnOrigin)}`)
}
const qs = qsParts.join('&')
const queryIndex = path.indexOf('?')
const routePath = queryIndex < 0 ? path : path.slice(0, queryIndex)
const query = queryIndex < 0 ? '' : path.slice(queryIndex + 1)
const businessParts = query.split('&').filter((part) => {
if (!part) return false
try {
const key = decodeURIComponent(part.split('=')[0])
return key !== 'mpTicket' && key !== 'mpReturnOrigin'
} catch {
return true
}
})
// 保留业务参数的原始编码,本次桥接信息覆盖旧票据,同时兼容根 query 启动。
return `${origin}/?${qs}#${routePath}?${[...businessParts, qs].join('&')}`
}
async function bootstrapWebView(path) {
const userStore = useUserStore()
userStore.checkLoginStatus()
if (!userStore.isLoggedIn) {
uni.reLaunch({ url: PAGE_PATHS.login })
return
}
try {
assertTicketEnvAligned()
const ticket = await fetchMpWebviewTicket()
const origin = String(resolveWebOrigin() || '').replace(/\/$/, '')
const returnOrigin =
typeof window !== 'undefined' && window.location && window.location.origin
? window.location.origin
: ''
pendingSrc = buildMpWebviewUrl(origin, path, ticket, returnOrigin)
tryMountWebView()
} catch (error) {
const msg = (error && error.message) || '无法打开内嵌页'
errorText.value = msg
uni.showToast({ title: msg, icon: 'none', duration: 3500 })
}
}
onLoad((query) => {
const path = normalizePath(query?.path)
if (!isAllowedPath(path)) {
errorText.value = '无效地址'
uni.showToast({ title: '无效地址', icon: 'none' })
setTimeout(() => {
goWorkbench()
}, 1200)
return
}
void bootstrapWebView(path)
})
onReady(() => {
pageReady.value = true
tryMountWebView()
})
onShow(() => {
// #ifdef MP-WEIXIN
try {
if (typeof uni.hideHomeButton === 'function') {
uni.hideHomeButton({})
}
// 中间标题留空,只留绿底与胶囊,和下方 H5 头视觉一体
uni.setNavigationBarTitle({ title: '\u200B' })
uni.setNavigationBarColor({
frontColor: '#ffffff',
backgroundColor: '#087f57'
})
} catch {
/* ignore */
}
// #endif
})
onMounted(() => {
// #ifdef H5
if (typeof window !== 'undefined') {
window.addEventListener('message', onHostMessage)
}
// #endif
})
onUnmounted(() => {
// #ifdef H5
if (typeof window !== 'undefined') {
window.removeEventListener('message', onHostMessage)
}
// #endif
})
</script>
<style scoped>
.mp-wv-page {
min-height: 100vh;
background: #f5f8f7;
}
.mp-wv-error {
padding: 32px 16px 8px;
color: #6b7b74;
font-size: 14px;
text-align: center;
}
.mp-wv-error__title {
color: #b45309;
font-size: 15px;
font-weight: 600;
}
</style>