AiUiCard.vue 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. <template>
  2. <el-result
  3. v-if="shouldShowFallback"
  4. class="ai-ui-card-result"
  5. icon="error"
  6. title=""
  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. data: 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 = props.data.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(str)
  44. return str
  45. })
  46. const iframeUrl = computed(() => {
  47. return win?.BpmTools?.$$make_ai_card_item_iframe?.(html.value) || ''
  48. })
  49. const iframeRef = ref<HTMLIFrameElement | null>(null)
  50. const isIframeLoadFailed = ref(false)
  51. const iframeSize = reactive({
  52. width: 0,
  53. height: 0
  54. })
  55. let resizeObserver: ResizeObserver | null = null
  56. let mutationObserver: MutationObserver | null = null
  57. let measureRaf = 0
  58. let validateToken = 0
  59. const shouldShowFallback = computed(() => !iframeUrl.value || isIframeLoadFailed.value)
  60. const iframeStyle = computed(
  61. (): CSSProperties => ({
  62. width: '100%',
  63. height: iframeSize.height ? `${iframeSize.height}px` : '0',
  64. visibility: iframeSize.height ? 'visible' : 'hidden'
  65. })
  66. )
  67. watch(
  68. iframeUrl,
  69. (url) => {
  70. const token = (validateToken += 1)
  71. isIframeLoadFailed.value = !url
  72. iframeSize.width = 0
  73. iframeSize.height = 0
  74. cleanupIframeWatchers()
  75. validateIframeUrl(url, token)
  76. },
  77. { immediate: true }
  78. )
  79. onBeforeUnmount(() => {
  80. validateToken += 1
  81. cleanupIframeWatchers()
  82. if (measureRaf) {
  83. cancelAnimationFrame(measureRaf)
  84. measureRaf = 0
  85. }
  86. })
  87. function cleanupIframeWatchers() {
  88. resizeObserver?.disconnect()
  89. mutationObserver?.disconnect()
  90. resizeObserver = null
  91. mutationObserver = null
  92. }
  93. function handleIframeError() {
  94. isIframeLoadFailed.value = true
  95. cleanupIframeWatchers()
  96. }
  97. function handleIframeLoad() {
  98. if (isIframeLoadFailed.value) {
  99. return
  100. }
  101. cleanupIframeWatchers()
  102. const doc = getIframeDocument()
  103. if (!doc?.body) {
  104. return
  105. }
  106. doc.documentElement.style.overflow = 'hidden'
  107. doc.body.style.margin = '0'
  108. doc.body.style.overflow = 'hidden'
  109. resizeObserver = new ResizeObserver(() => {
  110. scheduleMeasureIframe()
  111. })
  112. resizeObserver.observe(doc.documentElement)
  113. resizeObserver.observe(doc.body)
  114. observeBodyChildren(doc.body)
  115. mutationObserver = new MutationObserver(() => {
  116. observeBodyChildren(doc.body)
  117. scheduleMeasureIframe()
  118. })
  119. mutationObserver.observe(doc.body, {
  120. attributes: true,
  121. characterData: true,
  122. childList: true,
  123. subtree: true
  124. })
  125. scheduleMeasureIframe()
  126. }
  127. async function validateIframeUrl(url: string, token: number) {
  128. if (!url) {
  129. isIframeLoadFailed.value = true
  130. return
  131. }
  132. const parsedUrl = getSameOriginHttpUrl(url)
  133. if (!parsedUrl) return
  134. try {
  135. const response = await fetch(parsedUrl, {
  136. method: 'HEAD',
  137. credentials: 'same-origin'
  138. })
  139. if (token === validateToken && !response.ok) {
  140. isIframeLoadFailed.value = true
  141. cleanupIframeWatchers()
  142. }
  143. } catch {
  144. if (token === validateToken) {
  145. isIframeLoadFailed.value = true
  146. cleanupIframeWatchers()
  147. }
  148. }
  149. }
  150. function getSameOriginHttpUrl(url: string) {
  151. try {
  152. const parsedUrl = new URL(url, window.location.href)
  153. if (!['http:', 'https:'].includes(parsedUrl.protocol)) return ''
  154. if (parsedUrl.origin !== window.location.origin) return ''
  155. return parsedUrl.href
  156. } catch {
  157. return ''
  158. }
  159. }
  160. function getIframeDocument() {
  161. const iframe = iframeRef.value
  162. if (!iframe) return null
  163. try {
  164. return iframe.contentDocument || iframe.contentWindow?.document || null
  165. } catch {
  166. return null
  167. }
  168. }
  169. function observeBodyChildren(body: HTMLElement) {
  170. if (!resizeObserver) return
  171. Array.from(body.children).forEach((child) => {
  172. resizeObserver?.observe(child)
  173. })
  174. }
  175. function scheduleMeasureIframe() {
  176. if (measureRaf) {
  177. cancelAnimationFrame(measureRaf)
  178. }
  179. measureRaf = requestAnimationFrame(() => {
  180. measureRaf = requestAnimationFrame(() => {
  181. measureRaf = 0
  182. measureIframe()
  183. })
  184. })
  185. }
  186. function measureIframe() {
  187. const doc = getIframeDocument()
  188. if (!doc?.body) return
  189. const size = getContentSize(doc)
  190. iframeSize.height = size.height
  191. }
  192. function getContentSize(doc: Document) {
  193. const body = doc.body
  194. const root = doc.documentElement
  195. const children = Array.from(body.children)
  196. if (!children.length) {
  197. return {
  198. width: Math.ceil(Math.max(body.scrollWidth, root.scrollWidth)),
  199. height: Math.ceil(Math.max(body.scrollHeight, root.scrollHeight))
  200. }
  201. }
  202. const bodyRect = body.getBoundingClientRect()
  203. let left = 0
  204. let top = 0
  205. let right = 0
  206. let bottom = 0
  207. children.forEach((child) => {
  208. const rect = child.getBoundingClientRect()
  209. left = Math.min(left, rect.left - bodyRect.left)
  210. top = Math.min(top, rect.top - bodyRect.top)
  211. right = Math.max(right, rect.right - bodyRect.left)
  212. bottom = Math.max(bottom, rect.bottom - bodyRect.top)
  213. })
  214. return {
  215. width: '100%',
  216. height: Math.ceil(Math.max(bottom - top, body.scrollHeight, root.scrollHeight, 1))
  217. }
  218. }
  219. /**
  220. * 处理iframe消息
  221. * @param event
  222. */
  223. const handleMessage = (event: MessageEvent) => {
  224. const data = event.data || {}
  225. if (data?.type === 'ai-ui-card' && data?.data) {
  226. emit('submit', data.data)
  227. }
  228. }
  229. onBeforeUnmount(() => {
  230. window.removeEventListener('message', handleMessage)
  231. })
  232. onMounted(() => {
  233. window.addEventListener('message', handleMessage)
  234. })
  235. </script>
  236. <style scoped>
  237. .ai-ui-card-iframe {
  238. display: block;
  239. max-width: 100%;
  240. min-width: 360px;
  241. border: 0;
  242. overflow: hidden;
  243. }
  244. .ai-ui-card-result {
  245. width: 100%;
  246. min-width: 360px;
  247. padding: 16px 0;
  248. }
  249. </style>