mp-webview.vue 7.1 KB

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