深色模式
宿主代码示例
四种宿主形态的可用代码,复制过去改掉 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。 - 屏幕窄,报表会整列堆叠;组件多的看板在手机上读起来吃力,建议给手机单独做一张精简的报表。
