| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310 |
- <template>
- <el-result
- v-if="shouldShowFallback"
- class="ai-ui-card-result"
- icon="error"
- title="404"
- sub-title="卡片加载失败"
- />
- <iframe
- v-else
- ref="iframeRef"
- class="ai-ui-card-iframe"
- :src="iframeUrl"
- :style="iframeStyle"
- frameborder="0"
- scrolling="no"
- @error="handleIframeError"
- @load="handleIframeLoad"
- />
- </template>
- <script setup lang="ts">
- import type { CSSProperties } from 'vue'
- import { computed, onBeforeUnmount, reactive, ref, watch, onMounted } from 'vue'
- interface BpmToolsWindow extends Window {
- BpmTools?: {
- $$make_ai_card_item_iframe?: (html: string) => string | undefined
- $$open_ai_card_page?: (html: string, callback: (res: string) => void) => void
- }
- }
- const win = window as BpmToolsWindow
- const props = defineProps<{
- children: any
- attrs: Record<string, any>
- }>()
- const emit = defineEmits<{
- (e: 'submit', payload: string): void
- }>()
- const html = computed(() => {
- const text = extractTextFromVNodes(props.children).trim()
- const { attrs } = props
- const keys = Object.keys(attrs)
- const str = `<ai-ui-card ${keys.map((k) => `${k}="${attrs[k] || ''}"`).join(' ')}>${text || ''}</ai-ui-card>`
- console.log('card-dom:', str)
- return str
- })
- /**
- * 获取children的文本
- * @param vnodes
- */
- function extractTextFromVNodes(vnodes: any): string {
- if (!vnodes) return ''
- if (typeof vnodes === 'string') return vnodes
- let text = ''
- if (typeof vnodes === 'function') {
- text = vnodes()?.[0]
- }
- return text
- }
- const iframeUrl = computed(() => {
- return win?.BpmTools?.$$make_ai_card_item_iframe?.(html.value) || ''
- })
- const iframeRef = ref<HTMLIFrameElement | null>(null)
- const isIframeLoadFailed = ref(false)
- const iframeSize = reactive({
- width: 0,
- height: 0
- })
- let resizeObserver: ResizeObserver | null = null
- let mutationObserver: MutationObserver | null = null
- let measureRaf = 0
- let validateToken = 0
- const shouldShowFallback = computed(() => !iframeUrl.value || isIframeLoadFailed.value)
- const iframeStyle = computed(
- (): CSSProperties => ({
- width: '100%',
- height: iframeSize.height ? `${iframeSize.height}px` : '0',
- visibility: iframeSize.height ? 'visible' : 'hidden'
- })
- )
- watch(
- iframeUrl,
- (url) => {
- const token = (validateToken += 1)
- isIframeLoadFailed.value = !url
- iframeSize.width = 0
- iframeSize.height = 0
- cleanupIframeWatchers()
- validateIframeUrl(url, token)
- },
- { immediate: true }
- )
- onBeforeUnmount(() => {
- validateToken += 1
- cleanupIframeWatchers()
- if (measureRaf) {
- cancelAnimationFrame(measureRaf)
- measureRaf = 0
- }
- })
- function cleanupIframeWatchers() {
- resizeObserver?.disconnect()
- mutationObserver?.disconnect()
- resizeObserver = null
- mutationObserver = null
- }
- function handleIframeError() {
- isIframeLoadFailed.value = true
- cleanupIframeWatchers()
- }
- function handleIframeLoad() {
- if (isIframeLoadFailed.value) {
- return
- }
- cleanupIframeWatchers()
- const doc = getIframeDocument()
- if (!doc?.body) {
- return
- }
- doc.documentElement.style.overflow = 'hidden'
- doc.body.style.margin = '0'
- doc.body.style.overflow = 'hidden'
- resizeObserver = new ResizeObserver(() => {
- scheduleMeasureIframe()
- })
- resizeObserver.observe(doc.documentElement)
- resizeObserver.observe(doc.body)
- observeBodyChildren(doc.body)
- mutationObserver = new MutationObserver(() => {
- observeBodyChildren(doc.body)
- scheduleMeasureIframe()
- })
- mutationObserver.observe(doc.body, {
- attributes: true,
- characterData: true,
- childList: true,
- subtree: true
- })
- scheduleMeasureIframe()
- }
- async function validateIframeUrl(url: string, token: number) {
- if (!url) {
- isIframeLoadFailed.value = true
- return
- }
- const parsedUrl = getSameOriginHttpUrl(url)
- if (!parsedUrl) return
- try {
- const response = await fetch(parsedUrl, {
- method: 'HEAD',
- credentials: 'same-origin'
- })
- if (token === validateToken && !response.ok) {
- isIframeLoadFailed.value = true
- cleanupIframeWatchers()
- }
- } catch {
- if (token === validateToken) {
- isIframeLoadFailed.value = true
- cleanupIframeWatchers()
- }
- }
- }
- function getSameOriginHttpUrl(url: string) {
- try {
- const parsedUrl = new URL(url, window.location.href)
- if (!['http:', 'https:'].includes(parsedUrl.protocol)) return ''
- if (parsedUrl.origin !== window.location.origin) return ''
- return parsedUrl.href
- } catch {
- return ''
- }
- }
- function getIframeDocument() {
- const iframe = iframeRef.value
- if (!iframe) return null
- try {
- return iframe.contentDocument || iframe.contentWindow?.document || null
- } catch {
- return null
- }
- }
- function observeBodyChildren(body: HTMLElement) {
- if (!resizeObserver) return
- Array.from(body.children).forEach((child) => {
- resizeObserver?.observe(child)
- })
- }
- function scheduleMeasureIframe() {
- if (measureRaf) {
- cancelAnimationFrame(measureRaf)
- }
- measureRaf = requestAnimationFrame(() => {
- measureRaf = requestAnimationFrame(() => {
- measureRaf = 0
- measureIframe()
- })
- })
- }
- function measureIframe() {
- const doc = getIframeDocument()
- if (!doc?.body) return
- const size = getContentSize(doc)
- iframeSize.height = size.height
- }
- function getContentSize(doc: Document) {
- const body = doc.body
- const root = doc.documentElement
- const children = Array.from(body.children)
- if (!children.length) {
- return {
- width: Math.ceil(Math.max(body.scrollWidth, root.scrollWidth)),
- height: Math.ceil(Math.max(body.scrollHeight, root.scrollHeight))
- }
- }
- const bodyRect = body.getBoundingClientRect()
- let left = 0
- let top = 0
- let right = 0
- let bottom = 0
- children.forEach((child) => {
- const rect = child.getBoundingClientRect()
- left = Math.min(left, rect.left - bodyRect.left)
- top = Math.min(top, rect.top - bodyRect.top)
- right = Math.max(right, rect.right - bodyRect.left)
- bottom = Math.max(bottom, rect.bottom - bodyRect.top)
- })
- return {
- width: '100%',
- height: Math.ceil(Math.max(bottom - top, body.scrollHeight, root.scrollHeight, 1))
- }
- }
- /**
- * 处理iframe消息
- * @param event
- */
- const handleMessage = (event: MessageEvent) => {
- const data = event.data || {}
- if (data?.type === 'ai-ui-card' && data?.data) {
- emit('submit', data.data)
- }
- }
- onBeforeUnmount(() => {
- window.removeEventListener('message', handleMessage)
- })
- onMounted(() => {
- window.addEventListener('message', handleMessage)
- })
- </script>
- <style scoped>
- .ai-ui-card-iframe {
- display: block;
- max-width: 100%;
- min-width: 360px;
- border: 0;
- overflow: hidden;
- }
- .ai-ui-card-result {
- width: 100%;
- min-width: 360px;
- padding: 16px 0;
- }
- </style>
|