与宿主集成
报表组件不发网络请求、不存任何东西。宿主负责三件事:按请求取数,存取报表定义,决定谁能看、谁能改。
取数回调
查看器与设计器共用同一种取数回调 dataProvider(request, { signal }):拿到 core 按报表定义、参数与联动算出的请求,交回 Promise<DatasetData>。
| 请求字段 | 内容 |
|---|---|
dataset | 数据集 id |
parameters | 数据集参数的取值,已按绑定解析;没给值的参数不出现 |
filter | 已代入参数的筛选树;引用了计算字段时为 null,由 core 在取回的行上筛 |
sort、limit | 明细视图的排序与行数上限;聚合视图为 null |
query | 视图需要的聚合:维度、度量与 rollups;明细视图或引用了计算字段时为 null |
最直接的做法是把请求原样交给自己的接口:
export const dataProvider: ReportDataProvider = async (request, { signal }) => {
const response = await fetch(`/api/reports/datasets/${encodeURIComponent(request.dataset)}/query`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(request),
signal,
})
if (!response.ok)
throw new Error(`取数失败:${response.status}`)
return await response.json() as DatasetData
}- 同一轮里请求完全相同的几张卡片只调一次回调。
- 参数或联动变了、元素从页面上拿掉时,上一轮的
signal被取消,回调应随之中止请求。 - 回调抛错或拒绝时,这张卡片(或报表主体)显示「数据加载失败」,查看器派发
load-error。
交回明细,还是交回聚合
回调可以交回两种数据:
| 交回 | 谁来算 | |
|---|---|---|
| 明细行 | { kind: 'rows', rows },行以字段名为键 | core 在浏览器里筛选、分组、聚合与计算公式 |
| 已聚合的行 | { kind: 'aggregated', rows, rollups },行以维度 id 与度量 id 为键 | 宿主按 request.query 在服务端聚合,core 只做排版、排序与公式 |
请求的 query 为 null 时只能交回明细行;不为 null 时两种都行,数据量大时在服务端聚合就不必把明细搬到浏览器。avg、distinctCount 这类不能由下层累加的度量,小计与总计要单独算:query.rollups 列出要给的分组(每项是一组维度 id,空数组是总计),交回时放在 rollups 里。
下面模拟一个按请求聚合的后端,页面下方列出查看器发出的每个请求:
/** 在「服务端」按请求聚合:每组一行,另给出 rollups 列出的各组汇总。 */
function aggregate(request: DatasetRequest): DatasetData {
const query = request.query!
const run = (ids: readonly string[]): ReturnType<typeof runQuery> => runQuery(
{ dimensions: query.dimensions.filter(item => ids.includes(item.id)), measures: query.measures, filter: request.filter ?? undefined },
salesDataset,
{ kind: 'rows', rows },
{ timeZone: 'Asia/Shanghai' },
)
const leaf = run(query.dimensions.map(item => item.id))
const rollups: DatasetRollup[] = query.rollups.flatMap(ids => (ids.length === 0
? [{ dimensions: [], row: leaf.total }]
: run(ids).rows.map(row => ({ dimensions: ids, row }))))
return { kind: 'aggregated', rows: leaf.rows, rollups }
}const dataProvider: ReportDataProvider = async (request) => {
seen.push(`${request.dataset} ${request.query ? `聚合 ${request.query.dimensions.map(item => item.id).join('、') || '(总计)'}` : `明细 limit=${request.limit}`}`)
log.textContent = seen.join('\n')
return request.query ? aggregate(request) : { kind: 'rows', rows }
}存取报表定义
报表定义是纯 JSON。设计器派发 save 时宿主把 designer.schema 存进数据库或文件,存好后调 markSaved();业务页面取出它交给查看器的 schema。
@xihan-report/core 不依赖 DOM,服务端收到报表定义时可以在 Node 里先校验,有问题就拒收:
import type { ReportDocument, ValidationIssue } from '@xihan-report/core'
import { validateDocument } from '@xihan-report/core'
import { createExpressionEngine } from '@xihan-ui/expression'
const expressionEngine = createExpressionEngine()
export function acceptReport(schema: ReportDocument): { readonly ok: boolean, readonly issues: readonly ValidationIssue[] } {
const issues = validateDocument(schema, { expressionEngine })
return { ok: issues.length === 0, issues }
}权限
- 能不能改报表由宿主决定:只给能改的人打开设计器,或者打开时写
readonly;datasetEditing控制数据集能改到什么程度。 - 能看到哪些数据由取数接口决定。请求里的筛选来自报表定义与参数,服务端不能拿它当作权限的依据,要按登录的用户与租户自己加上限制。
- 报表里不含也不执行任何代码,计算字段与公式用受限表达式,导入报表定义不会带来脚本注入。
在框架里使用
元素是原生 Web Components,任何框架都能直接用。复杂数据交给元素的属性,不走 attribute;事件是冒泡的 CustomEvent。
在 Vue 里,先在 @vitejs/plugin-vue 的 template.compilerOptions 里写 isCustomElement: tag => tag.startsWith('xh-'),免得 xh- 开头的标签被当成组件去解析;之后用 .prop 把对象交给元素的属性,用 @ 监听事件:
<script setup lang="ts">
/*
* Copyright (c) 2021-Present XiHanFun and contributors.
* Licensed under the MIT License. See LICENSE in the project root for license information.
*/
// 在 Vue 里使用查看器:对象经 .prop 交给元素的属性,事件用 @ 监听;参数由 Vue 的状态管着(受控)。
import type { ParameterValues } from '@xihan-report/core'
import type { ReportDataProvider, ReportViewerEventMap } from '@xihan-report/runtime'
import { defineReportViewer } from '@xihan-report/runtime'
import { createExpressionEngine } from '@xihan-ui/expression'
import { ref } from 'vue'
import { rows, schema } from '../reports/regional.ts'
defineReportViewer()
const expressionEngine = createExpressionEngine()
const dataProvider: ReportDataProvider = async () => ({ kind: 'rows', rows })
const parameters = ref<ParameterValues>({ region: '华南' })
function onParametersChange(event: Event): void {
parameters.value = (event as ReportViewerEventMap['parameters-change']).detail.value
}
</script>
<template>
<p>区域:{{ parameters.region ?? '全部' }}</p>
<xh-report-viewer
:schema.prop="schema"
:data-provider.camel.prop="dataProvider"
:expression-engine.camel.prop="expressionEngine"
:parameters.prop="parameters"
@parameters-change="onParametersChange"
/>
</template>React 19 起会把对象直接赋给自定义元素的同名属性;事件可以用 ref 拿到元素后 addEventListener 监听。
