Skip to content

宿主代码示例

四种宿主形态的可用代码,复制过去改掉 REPORT_ORIGIN 就能跑。

原生 HTML

一个可复用的小封装,不依赖任何框架:

html
<div id="box" style="width:100%"></div>

<script>
function mountReport(container, { origin, id, params = {}, lock, chrome = true, version }) {
  const q = new URLSearchParams({ id, view: '1' })
  for (const [k, v] of Object.entries(params)) {
    if (v !== null && v !== undefined) q.set(k, Array.isArray(v) ? v.join(',') : String(v))
  }
  if (lock) q.set('lock', Array.isArray(lock) ? lock.join(',') : lock)
  if (chrome === false) q.set('chrome', '0')
  if (version) q.set('version', version)

  const frame = document.createElement('iframe')
  frame.src = `${origin}/?${q}`
  frame.style.cssText = 'width:100%;height:480px;border:0'
  container.appendChild(frame)

  let ready = false
  const queue = []
  const post = (msg) => {
    if (!ready) { queue.push(msg); return }
    frame.contentWindow.postMessage({ source: 'conch-report-host', ...msg }, origin)
  }

  const onMessage = (e) => {
    if (e.origin !== origin || e.source !== frame.contentWindow) return
    const msg = e.data
    if (!msg || msg.source !== 'conch-report') return
    if (msg.type === 'height') frame.style.height = msg.height + 'px'
    if (msg.type === 'ready') { ready = true; queue.splice(0).forEach(post) }
  }
  window.addEventListener('message', onMessage)

  return {
    setParams: (params) => post({ type: 'setParams', params }),
    refresh: () => post({ type: 'refresh' }),
    setTheme: (tokens) => post({ type: 'setTheme', tokens }),
    destroy: () => { window.removeEventListener('message', onMessage); frame.remove() },
  }
}

const report = mountReport(document.getElementById('box'), {
  origin: 'https://report.example.com',
  id: 'r260922123456',
  params: { month: '2026-08' },
  lock: 'store',
})
// report.setParams({ month: '2026-07' })
</script>

Vue 3

ConchReport.vue

vue
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'

const props = defineProps({
  origin: { type: String, required: true },
  reportId: { type: String, required: true },
  params: { type: Object, default: () => ({}) },
  lock: { type: [String, Array], default: '' },
  chrome: { type: Boolean, default: true },
  version: { type: String, default: '' },
})
const emit = defineEmits(['ready', 'params'])

const frame = ref(null)
const height = ref(480)
const ready = ref(false)

const src = computed(() => {
  const q = new URLSearchParams({ id: props.reportId, view: '1' })
  for (const [k, v] of Object.entries(props.params)) {
    if (v !== null && v !== undefined) q.set(k, Array.isArray(v) ? v.join(',') : String(v))
  }
  const lock = Array.isArray(props.lock) ? props.lock.join(',') : props.lock
  if (lock) q.set('lock', lock)
  if (!props.chrome) q.set('chrome', '0')
  if (props.version) q.set('version', props.version)
  return `${props.origin}/?${q}`
})

function post(msg) {
  if (!ready.value) return
  frame.value?.contentWindow?.postMessage({ source: 'conch-report-host', ...msg }, props.origin)
}

function onMessage(e) {
  if (e.origin !== props.origin || e.source !== frame.value?.contentWindow) return
  const msg = e.data
  if (!msg || msg.source !== 'conch-report') return
  if (msg.type === 'height') height.value = msg.height
  if (msg.type === 'ready') { ready.value = true; emit('ready', msg) }
  if (msg.type === 'params') emit('params', msg.params)
}

onMounted(() => window.addEventListener('message', onMessage))
onBeforeUnmount(() => window.removeEventListener('message', onMessage))

// 条件变了走消息,不重新加载 iframe(重载会让图表重画一遍)
watch(() => props.params, (v) => post({ type: 'setParams', params: v }), { deep: true })

defineExpose({ refresh: () => post({ type: 'refresh' }), setTheme: (tokens) => post({ type: 'setTheme', tokens }) })
</script>

<template>
  <iframe ref="frame" :src="src" :style="{ width: '100%', height: height + 'px', border: 0 }" />
</template>

用:

vue
<ConchReport
  origin="https://report.example.com"
  report-id="r260922123456"
  :params="{ month, store: myStores }"
  lock="store"
  @ready="(m) => console.log('打开了', m.title)"
/>

React

jsx
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

export function ConchReport({ origin, reportId, params = {}, lock, chrome = true, version, onReady, onParams }) {
  const frameRef = useRef(null)
  const readyRef = useRef(false)
  const [height, setHeight] = useState(480)

  const src = useMemo(() => {
    const q = new URLSearchParams({ id: reportId, view: '1' })
    for (const [k, v] of Object.entries(params)) {
      if (v !== null && v !== undefined) q.set(k, Array.isArray(v) ? v.join(',') : String(v))
    }
    if (lock) q.set('lock', Array.isArray(lock) ? lock.join(',') : lock)
    if (!chrome) q.set('chrome', '0')
    if (version) q.set('version', version)
    return `${origin}/?${q}`
    // 首次挂载用参数拼 src;之后条件变化走 setParams,不换 src
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [origin, reportId])

  const post = useCallback((msg) => {
    if (!readyRef.current) return
    frameRef.current?.contentWindow?.postMessage({ source: 'conch-report-host', ...msg }, origin)
  }, [origin])

  useEffect(() => {
    const onMessage = (e) => {
      if (e.origin !== origin || e.source !== frameRef.current?.contentWindow) return
      const msg = e.data
      if (!msg || msg.source !== 'conch-report') return
      if (msg.type === 'height') setHeight(msg.height)
      if (msg.type === 'ready') { readyRef.current = true; onReady?.(msg) }
      if (msg.type === 'params') onParams?.(msg.params)
    }
    window.addEventListener('message', onMessage)
    return () => window.removeEventListener('message', onMessage)
  }, [origin, onReady, onParams])

  useEffect(() => { post({ type: 'setParams', params }) }, [JSON.stringify(params)]) // eslint-disable-line

  return <iframe ref={frameRef} src={src} style={{ width: '100%', height, border: 0 }} />
}

后台框架里挂一个菜单页

若依、ABP 这类后台前端,做法都是「加一个路由页,页面里就一个 iframe」,报表 id 从路由参数或菜单配置来:

js
// 路由:/report/:id
{ path: '/report/:id', component: () => import('@/views/report/embed.vue') }
vue
<script setup>
import { useRoute } from 'vue-router'
import { useUserStore } from '@/store/user'
import ConchReport from '@/components/ConchReport.vue'

const route = useRoute()
const user = useUserStore()
// 宿主按登录身份决定口径,并锁住不让改
const params = { month: new Date().toISOString().slice(0, 7), dept: user.deptCode }
</script>

<template>
  <div class="app-container">
    <ConchReport origin="https://report.example.com" :report-id="route.params.id"
                 :params="params" lock="dept" :chrome="false" />
  </div>
</template>

菜单里配几张报表,就把几张报表的 id 填进菜单的路由参数。要列出全部报表由宿主自己渲染列表,调报表列表接口

锁住不等于限权

上面把 dept 锁住只是界面上不让改。谁都可以把 iframe 的地址抄出来改掉 dept。真正按科室限制数据要在报表服务侧做行级范围,见部署与安全

微信小程序 web-view

json
{ "usingComponents": {} }
html
<!-- pages/report/index.wxml -->
<web-view src="{{url}}"></web-view>
js
// pages/report/index.js
Page({
  data: { url: '' },
  onLoad(query) {
    const base = 'https://report.example.com'
    const params = new URLSearchParams({ id: query.id, view: '1', month: query.month || '', chrome: '0' })
    this.setData({ url: `${base}/?${params}` })
  },
})

三点限制:

  • 域名要先在小程序后台配成业务域名,否则 web-view 打不开。
  • 收不到也发不出 postMessage:条件要变就 setData 换一次 url
  • 屏幕窄,报表会整列堆叠;组件多的看板在手机上读起来吃力,建议给手机单独做一张精简的报表。

给 AI 用的纯文本索引在 /llms.txt,每篇原文在 /md/ 下。