275 lines
7.1 KiB
Vue
275 lines
7.1 KiB
Vue
<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 { request, parseResponseJson } from '@/utils/request'
|
||
import { useUserStore } from '@/stores/user'
|
||
|
||
/** 热更新错配时:本地签发 ticket、生产打开 H5 → 401 */
|
||
function assertTicketEnvAligned() {
|
||
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}。请停止后重新「运行到小程序」完整编译,不要只靠热更新。`
|
||
)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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
|
||
: ''
|
||
const qsParts = [`mpTicket=${encodeURIComponent(ticket)}`]
|
||
if (returnOrigin) {
|
||
qsParts.push(`mpReturnOrigin=${encodeURIComponent(returnOrigin)}`)
|
||
}
|
||
const qs = qsParts.join('&')
|
||
pendingSrc = `${origin}/?${qs}#${path}?${qs}`
|
||
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>
|