AiUiCard.vue 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. <template>
  2. <el-result
  3. v-if="shouldShowFallback"
  4. class="ai-ui-card-result"
  5. icon="error"
  6. title="404"
  7. sub-title="卡片加载失败"
  8. />
  9. <iframe
  10. v-else
  11. ref="iframeRef"
  12. class="ai-ui-card-iframe"
  13. :src="iframeUrl"
  14. :style="iframeStyle"
  15. frameborder="0"
  16. scrolling="no"
  17. @error="handleIframeError"
  18. @load="handleIframeLoad"
  19. />
  20. </template>
  21. <script setup lang="ts">
  22. import type { CSSProperties } from 'vue'
  23. import { computed, onBeforeUnmount, reactive, ref, watch, onMounted } from 'vue'
  24. interface BpmToolsWindow extends Window {
  25. BpmTools?: {
  26. $$make_ai_card_item_iframe?: (html: string) => string | undefined
  27. $$open_ai_card_page?: (html: string, callback: (res: string) => void) => void
  28. }
  29. }
  30. const win = window as BpmToolsWindow
  31. const props = defineProps<{
  32. children: any
  33. attrs: Record<string, any>
  34. }>()
  35. const emit = defineEmits<{
  36. (e: 'submit', payload: string): void
  37. }>()
  38. const html = computed(() => {
  39. const text = extractTextFromVNodes(props.children).trim()
  40. const { attrs } = props
  41. const keys = Object.keys(attrs)
  42. const str = `<ai-ui-card ${keys.map((k) => `${k}="${attrs[k] || ''}"`).join(' ')}>${text || ''}</ai-ui-card>`
  43. console.log('card-dom:', str)
  44. return str
  45. })
  46. /**
  47. * 获取children的文本
  48. * @param vnodes
  49. */
  50. function extractTextFromVNodes(vnodes: any): string {
  51. if (!vnodes) return ''
  52. if (typeof vnodes === 'string') return vnodes
  53. let text = ''
  54. if (typeof vnodes === 'function') {
  55. text = vnodes()?.[0]
  56. }
  57. return text
  58. }
  59. const iframeUrl = computed(() => {
  60. return win?.BpmTools?.$$make_ai_card_item_iframe?.(html.value) || ''
  61. })
  62. const iframeRef = ref<HTMLIFrameElement | null>(null)
  63. const isIframeLoadFailed = ref(false)
  64. const iframeSize = reactive({
  65. width: 0,
  66. height: 0
  67. })
  68. let resizeObserver: ResizeObserver | null = null
  69. let mutationObserver: MutationObserver | null = null
  70. let measureRaf = 0
  71. let validateToken = 0
  72. const shouldShowFallback = computed(() => !iframeUrl.value || isIframeLoadFailed.value)
  73. const iframeStyle = computed(
  74. (): CSSProperties => ({
  75. width: '100%',
  76. height: iframeSize.height ? `${iframeSize.height}px` : '0',
  77. visibility: iframeSize.height ? 'visible' : 'hidden'
  78. })
  79. )
  80. watch(
  81. iframeUrl,
  82. (url) => {
  83. const token = (validateToken += 1)
  84. isIframeLoadFailed.value = !url
  85. iframeSize.width = 0
  86. iframeSize.height = 0
  87. cleanupIframeWatchers()
  88. validateIframeUrl(url, token)
  89. },
  90. { immediate: true }
  91. )
  92. onBeforeUnmount(() => {
  93. validateToken += 1
  94. cleanupIframeWatchers()
  95. if (measureRaf) {
  96. cancelAnimationFrame(measureRaf)
  97. measureRaf = 0
  98. }
  99. })
  100. function cleanupIframeWatchers() {
  101. resizeObserver?.disconnect()
  102. mutationObserver?.disconnect()
  103. resizeObserver = null
  104. mutationObserver = null
  105. }
  106. function handleIframeError() {
  107. isIframeLoadFailed.value = true
  108. cleanupIframeWatchers()
  109. }
  110. function handleIframeLoad() {
  111. if (isIframeLoadFailed.value) {
  112. return
  113. }
  114. cleanupIframeWatchers()
  115. const doc = getIframeDocument()
  116. if (!doc?.body) {
  117. return
  118. }
  119. doc.documentElement.style.overflow = 'hidden'
  120. doc.body.style.margin = '0'
  121. doc.body.style.overflow = 'hidden'
  122. resizeObserver = new ResizeObserver(() => {
  123. scheduleMeasureIframe()
  124. })
  125. resizeObserver.observe(doc.documentElement)
  126. resizeObserver.observe(doc.body)
  127. observeBodyChildren(doc.body)
  128. mutationObserver = new MutationObserver(() => {
  129. observeBodyChildren(doc.body)
  130. scheduleMeasureIframe()
  131. })
  132. mutationObserver.observe(doc.body, {
  133. attributes: true,
  134. characterData: true,
  135. childList: true,
  136. subtree: true
  137. })
  138. scheduleMeasureIframe()
  139. }
  140. async function validateIframeUrl(url: string, token: number) {
  141. if (!url) {
  142. isIframeLoadFailed.value = true
  143. return
  144. }
  145. const parsedUrl = getSameOriginHttpUrl(url)
  146. if (!parsedUrl) return
  147. try {
  148. const response = await fetch(parsedUrl, {
  149. method: 'HEAD',
  150. credentials: 'same-origin'
  151. })
  152. if (token === validateToken && !response.ok) {
  153. isIframeLoadFailed.value = true
  154. cleanupIframeWatchers()
  155. }
  156. } catch {
  157. if (token === validateToken) {
  158. isIframeLoadFailed.value = true
  159. cleanupIframeWatchers()
  160. }
  161. }
  162. }
  163. function getSameOriginHttpUrl(url: string) {
  164. try {
  165. const parsedUrl = new URL(url, window.location.href)
  166. if (!['http:', 'https:'].includes(parsedUrl.protocol)) return ''
  167. if (parsedUrl.origin !== window.location.origin) return ''
  168. return parsedUrl.href
  169. } catch {
  170. return ''
  171. }
  172. }
  173. function getIframeDocument() {
  174. const iframe = iframeRef.value
  175. if (!iframe) return null
  176. try {
  177. return iframe.contentDocument || iframe.contentWindow?.document || null
  178. } catch {
  179. return null
  180. }
  181. }
  182. function observeBodyChildren(body: HTMLElement) {
  183. if (!resizeObserver) return
  184. Array.from(body.children).forEach((child) => {
  185. resizeObserver?.observe(child)
  186. })
  187. }
  188. function scheduleMeasureIframe() {
  189. if (measureRaf) {
  190. cancelAnimationFrame(measureRaf)
  191. }
  192. measureRaf = requestAnimationFrame(() => {
  193. measureRaf = requestAnimationFrame(() => {
  194. measureRaf = 0
  195. measureIframe()
  196. })
  197. })
  198. }
  199. function measureIframe() {
  200. const doc = getIframeDocument()
  201. if (!doc?.body) return
  202. const size = getContentSize(doc)
  203. iframeSize.height = size.height
  204. }
  205. function getContentSize(doc: Document) {
  206. const body = doc.body
  207. const root = doc.documentElement
  208. const children = Array.from(body.children)
  209. if (!children.length) {
  210. return {
  211. width: Math.ceil(Math.max(body.scrollWidth, root.scrollWidth)),
  212. height: Math.ceil(Math.max(body.scrollHeight, root.scrollHeight))
  213. }
  214. }
  215. const bodyRect = body.getBoundingClientRect()
  216. let left = 0
  217. let top = 0
  218. let right = 0
  219. let bottom = 0
  220. children.forEach((child) => {
  221. const rect = child.getBoundingClientRect()
  222. left = Math.min(left, rect.left - bodyRect.left)
  223. top = Math.min(top, rect.top - bodyRect.top)
  224. right = Math.max(right, rect.right - bodyRect.left)
  225. bottom = Math.max(bottom, rect.bottom - bodyRect.top)
  226. })
  227. return {
  228. width: '100%',
  229. height: Math.ceil(Math.max(bottom - top, body.scrollHeight, root.scrollHeight, 1))
  230. }
  231. }
  232. /**
  233. * 处理iframe消息
  234. * @param event
  235. */
  236. const handleMessage = (event: MessageEvent) => {
  237. const data = event.data || {}
  238. if (data?.type === 'ai-ui-card' && data?.data) {
  239. emit('submit', data.data)
  240. }
  241. }
  242. onBeforeUnmount(() => {
  243. window.removeEventListener('message', handleMessage)
  244. })
  245. onMounted(() => {
  246. window.addEventListener('message', handleMessage)
  247. })
  248. </script>
  249. <style scoped>
  250. .ai-ui-card-iframe {
  251. display: block;
  252. max-width: 100%;
  253. min-width: 360px;
  254. border: 0;
  255. overflow: hidden;
  256. }
  257. .ai-ui-card-result {
  258. width: 100%;
  259. min-width: 360px;
  260. padding: 16px 0;
  261. }
  262. </style>