mp-webview.vue 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. <template>
  2. <view class="mp-wv-page">
  3. <view v-if="errorText" class="mp-wv-error">
  4. <text class="mp-wv-error__title">{{ errorText }}</text>
  5. </view>
  6. <!-- 等 onReady 后再挂载 web-view,避免跳转尚未完成时嵌入 H5 导致 navigateTo:fail timeout -->
  7. <web-view v-else-if="src" :src="src" @message="onWebViewMessage" />
  8. <view v-else class="mp-wv-error">加载中…</view>
  9. </view>
  10. </template>
  11. <script setup>
  12. import { onMounted, onUnmounted, ref } from 'vue'
  13. import { onLoad, onReady, onShow } from '@dcloudio/uni-app'
  14. import { AUTH_API, PAGE_PATHS, resolveApiBase, resolveWebOrigin } from '@/config/api'
  15. import { IS_TEST_LOCAL_H5 } from '@/config/env'
  16. import { request, parseResponseJson } from '@/utils/request'
  17. import { useUserStore } from '@/stores/user'
  18. /** 热更新错配时:本地签发 ticket、生产打开 H5 → 401 */
  19. function assertTicketEnvAligned() {
  20. // 唯一合法混合:APP_ENV=testLocalH5(测试 API + 本机 H5),由 env 预设保证地址配对
  21. if (IS_TEST_LOCAL_H5) return
  22. const api = resolveApiBase()
  23. const web = resolveWebOrigin()
  24. const apiLocal = /127\.0\.0\.1|localhost/.test(api)
  25. const webLocal = /127\.0\.0\.1|localhost/.test(web)
  26. const apiProd = /shanxiachem\.com/.test(api)
  27. const webProd = /shanxiachem\.com/.test(web)
  28. if (apiLocal !== webLocal || apiProd !== webProd) {
  29. throw new Error(
  30. `环境错配:API=${api},Web=${web}。请改 config/env.js 的 APP_ENV,并重新「运行到小程序」完整编译。`
  31. )
  32. }
  33. }
  34. const MP_WEBVIEW_GO_WORKBENCH = 'MP_WEBVIEW_GO_WORKBENCH'
  35. const MP_WEBVIEW_COPY_TEXT = 'MP_WEBVIEW_COPY_TEXT'
  36. const src = ref('')
  37. const errorText = ref('')
  38. const pageReady = ref(false)
  39. /** ticket 就绪后的完整 H5 URL,等 pageReady 再赋给 src */
  40. let pendingSrc = ''
  41. function normalizePath(raw) {
  42. let path = String(raw || '').trim()
  43. for (let i = 0; i < 3; i++) {
  44. if (!path || path.startsWith('/m/')) break
  45. try {
  46. const next = decodeURIComponent(path)
  47. if (next === path) break
  48. path = next.trim()
  49. } catch {
  50. break
  51. }
  52. }
  53. return path
  54. }
  55. function isAllowedPath(path) {
  56. return typeof path === 'string' && path.startsWith('/m/')
  57. }
  58. function goWorkbench() {
  59. uni.reLaunch({ url: PAGE_PATHS.workbench })
  60. }
  61. function tryMountWebView() {
  62. if (!pageReady.value || !pendingSrc || errorText.value) return
  63. src.value = pendingSrc
  64. pendingSrc = ''
  65. }
  66. function fallbackExecCommandCopy(text) {
  67. // #ifdef H5
  68. try {
  69. if (typeof document === 'undefined') return false
  70. const textarea = document.createElement('textarea')
  71. textarea.value = text
  72. textarea.setAttribute('readonly', '')
  73. textarea.style.position = 'fixed'
  74. textarea.style.left = '-9999px'
  75. document.body.appendChild(textarea)
  76. textarea.select()
  77. const ok = document.execCommand('copy')
  78. document.body.removeChild(textarea)
  79. return ok
  80. } catch {
  81. return false
  82. }
  83. // #endif
  84. // #ifndef H5
  85. return false
  86. // #endif
  87. }
  88. async function copyTextOnHost(text) {
  89. const value = String(text || '')
  90. if (!value) return false
  91. // #ifdef H5
  92. try {
  93. if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
  94. await navigator.clipboard.writeText(value)
  95. return true
  96. }
  97. } catch {
  98. /* fall through */
  99. }
  100. if (fallbackExecCommandCopy(value)) {
  101. return true
  102. }
  103. // #endif
  104. return await new Promise((resolve) => {
  105. uni.setClipboardData({
  106. data: value,
  107. success: () => resolve(true),
  108. fail: () => resolve(false)
  109. })
  110. })
  111. }
  112. function handleHostPayload(data) {
  113. if (!data || typeof data !== 'object') return
  114. if (data.type === MP_WEBVIEW_GO_WORKBENCH) {
  115. goWorkbench()
  116. return
  117. }
  118. if (data.type === MP_WEBVIEW_COPY_TEXT) {
  119. void copyTextOnHost(data.text)
  120. }
  121. }
  122. function onWebViewMessage(event) {
  123. const list = event && event.detail && event.detail.data
  124. if (!Array.isArray(list)) return
  125. for (let i = list.length - 1; i >= 0; i--) {
  126. handleHostPayload(list[i])
  127. }
  128. }
  129. function onHostMessage(event) {
  130. handleHostPayload(event && event.data)
  131. }
  132. async function fetchMpWebviewTicket(maxAttempts = 3) {
  133. let lastMessage = '无法打开内嵌页'
  134. for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  135. try {
  136. const res = await request({
  137. url: AUTH_API.MP_WEBVIEW_TICKET,
  138. method: 'POST'
  139. })
  140. const result = parseResponseJson(res)
  141. const code = result && result.code
  142. const message = (result && result.message) || ''
  143. const ticket = code === 200 ? result.data?.ticket : ''
  144. if (ticket) return ticket
  145. lastMessage = message || lastMessage
  146. if (Number(code) === 503 && attempt < maxAttempts) {
  147. await new Promise((r) => setTimeout(r, 300 * attempt))
  148. continue
  149. }
  150. break
  151. } catch (error) {
  152. const errMsg = (error && error.message) || String(error)
  153. lastMessage = errMsg || lastMessage
  154. if (/timeout|fail|网络/i.test(String(lastMessage)) && attempt < maxAttempts) {
  155. await new Promise((r) => setTimeout(r, 300 * attempt))
  156. continue
  157. }
  158. throw error
  159. }
  160. }
  161. throw new Error(lastMessage)
  162. }
  163. function buildMpWebviewUrl(origin, path, ticket, returnOrigin) {
  164. const qsParts = [`mpTicket=${encodeURIComponent(ticket)}`]
  165. if (returnOrigin) {
  166. qsParts.push(`mpReturnOrigin=${encodeURIComponent(returnOrigin)}`)
  167. }
  168. const qs = qsParts.join('&')
  169. const queryIndex = path.indexOf('?')
  170. const routePath = queryIndex < 0 ? path : path.slice(0, queryIndex)
  171. const query = queryIndex < 0 ? '' : path.slice(queryIndex + 1)
  172. const businessParts = query.split('&').filter((part) => {
  173. if (!part) return false
  174. try {
  175. const key = decodeURIComponent(part.split('=')[0])
  176. return key !== 'mpTicket' && key !== 'mpReturnOrigin'
  177. } catch {
  178. return true
  179. }
  180. })
  181. // 保留业务参数的原始编码,本次桥接信息覆盖旧票据,同时兼容根 query 启动。
  182. return `${origin}/?${qs}#${routePath}?${[...businessParts, qs].join('&')}`
  183. }
  184. async function bootstrapWebView(path) {
  185. const userStore = useUserStore()
  186. userStore.checkLoginStatus()
  187. if (!userStore.isLoggedIn) {
  188. uni.reLaunch({ url: PAGE_PATHS.login })
  189. return
  190. }
  191. try {
  192. assertTicketEnvAligned()
  193. const ticket = await fetchMpWebviewTicket()
  194. const origin = String(resolveWebOrigin() || '').replace(/\/$/, '')
  195. const returnOrigin =
  196. typeof window !== 'undefined' && window.location && window.location.origin
  197. ? window.location.origin
  198. : ''
  199. pendingSrc = buildMpWebviewUrl(origin, path, ticket, returnOrigin)
  200. tryMountWebView()
  201. } catch (error) {
  202. const msg = (error && error.message) || '无法打开内嵌页'
  203. errorText.value = msg
  204. uni.showToast({ title: msg, icon: 'none', duration: 3500 })
  205. }
  206. }
  207. onLoad((query) => {
  208. const path = normalizePath(query?.path)
  209. if (!isAllowedPath(path)) {
  210. errorText.value = '无效地址'
  211. uni.showToast({ title: '无效地址', icon: 'none' })
  212. setTimeout(() => {
  213. goWorkbench()
  214. }, 1200)
  215. return
  216. }
  217. void bootstrapWebView(path)
  218. })
  219. onReady(() => {
  220. pageReady.value = true
  221. tryMountWebView()
  222. })
  223. onShow(() => {
  224. // #ifdef MP-WEIXIN
  225. try {
  226. if (typeof uni.hideHomeButton === 'function') {
  227. uni.hideHomeButton({})
  228. }
  229. // 中间标题留空,只留绿底与胶囊,和下方 H5 头视觉一体
  230. uni.setNavigationBarTitle({ title: '\u200B' })
  231. uni.setNavigationBarColor({
  232. frontColor: '#ffffff',
  233. backgroundColor: '#087f57'
  234. })
  235. } catch {
  236. /* ignore */
  237. }
  238. // #endif
  239. })
  240. onMounted(() => {
  241. // #ifdef H5
  242. if (typeof window !== 'undefined') {
  243. window.addEventListener('message', onHostMessage)
  244. }
  245. // #endif
  246. })
  247. onUnmounted(() => {
  248. // #ifdef H5
  249. if (typeof window !== 'undefined') {
  250. window.removeEventListener('message', onHostMessage)
  251. }
  252. // #endif
  253. })
  254. </script>
  255. <style scoped>
  256. .mp-wv-page {
  257. min-height: 100vh;
  258. background: #f5f8f7;
  259. }
  260. .mp-wv-error {
  261. padding: 32px 16px 8px;
  262. color: #6b7b74;
  263. font-size: 14px;
  264. text-align: center;
  265. }
  266. .mp-wv-error__title {
  267. color: #b45309;
  268. font-size: 15px;
  269. font-weight: 600;
  270. }
  271. </style>