| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- <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>
|