26 KiB
Web Report Designer 初期架构设计方案 V1
文档状态:初期架构设计
本版本目标是先建立稳定的文档模型、布局管线和编辑器边界,不在第一阶段一次性实现所有复杂报表能力。
1. 项目定位
项目暂定名称:
Web Report Designer
包名暂定:
@xxx/report-core
@xxx/report-expression
@xxx/report-layout
@xxx/report-renderer
@xxx/report-designer
@xxx/report-designer-vue
项目定位:
一个面向业务系统的、纯 Web 的、可嵌入、可扩展、插件式的报表设计与渲染平台。
重点解决:
- 固定版式业务报表
- 打印单据
- A4/A5/自定义纸张
- 主表 + 子表
- 动态明细
- 多页打印
- 页眉页脚
- 基础分组和合计
- 数据绑定
- 受限表达式
- HTML、SVG 和浏览器打印
- 可扩展的 PDF 输出接口
明确不包含:
BI
Dashboard
OLAP
数据分析
数据立方体
指标体系
大屏
1.1 V1 初期范围
V1 优先保证以下能力可用:
- 文本、图片、线条、矩形等固定元素。
- A4、A5 和自定义纸张。
- 基础数据绑定和明细表。
- 页眉、页脚和基础分页。
- HTML、SVG 预览以及浏览器打印。
- 文档序列化、校验和版本迁移。
- 可注册的元素插件接口。
以下能力先预留模型和接口,放到后续迭代:
- 行列合并、跨页合计的分页级精细控制。
- 图表、条码、二维码等插件实现。
- 服务端 PDF 的具体实现。
- 协同编辑、权限体系和 BI 能力。
实现进度补充:基础分组(group-header / group-footer、多级分组、组内聚合) 已提前完成,详见 §16 阶段 3。
2. 总体架构
整体采用 Monorepo。
report-designer/
│
├── apps/
│ └── report-designer/
│
├── packages/
│ ├── report-core/
│ ├── report-expression/
│ ├── report-layout/
│ ├── report-renderer/
│ │ └── src/
│ │ ├── html/
│ │ ├── svg/
│ │ └── pdf/
│ ├── report-designer/
│ ├── report-designer-vue/
│ └── plugins/
│ ├── qrcode/
│ ├── barcode/
│ ├── chart/
│ └── ...
│
├── examples/
├── pnpm-workspace.yaml
├── package.json
└── README.md
2.1 依赖方向
report-expression ──> report-core
report-layout ──> report-core + report-expression
report-renderer ──> report-core + report-layout
report-designer ──> report-core + report-layout
report-designer-vue ──> report-designer
上图箭头表示“依赖”。Core 不依赖上述任何实现包。
Renderer 只消费布局结果,不重新计算分页。插件实现可以依赖 Core、Layout、Renderer 或 Designer 的适配器,但 Core 不反向依赖任何具体插件。
plugin-qrcode
├── core element contract
├── layout adapter
├── renderer adapter
└── designer adapter(可选)
2.2 运行时主流程
ReportDocument
│
▼
数据提供与参数注入
│
▼
表达式解析与绑定求值
│
▼
LayoutEngine(测量、展开、换行、分页)
│
▼
LayoutDocument(统一的分页结果)
│
├── HTML Renderer
├── SVG Renderer
└── PDF Renderer
3. 核心架构原则
3.1 Core 与 UI 完全分离
report-core:
纯 TypeScript
无 Vue
无 React
无 DOM
无浏览器 API
无 UI 框架
Core 可以在 Browser、Node.js、Electron、Server 和 CLI 中复用。
3.2 文档模型与运行时状态分离
ReportDocument 只保存可序列化的报表定义,不保存以下编辑器状态:
- 当前选中项。
- 缩放比例和画布滚动位置。
- 辅助线、吸附线和临时选区。
- 撤销栈和 redo 栈。
- 已加载的数据和网络连接。
3.3 Layout 与 Renderer 分离
Layout 负责:
尺寸计算
文本测量
换行
表格展开
动态高度
分页
Renderer 负责:
把 LayoutDocument 绘制成 HTML、SVG 或 PDF
这样可以保证不同输出目标使用同一套分页结果。
3.4 运行时数据由外部注入
Core 不负责 fetch、数据库连接或 Token 管理。数据源定义和运行时数据提供器分开,避免把敏感配置写入报表文件。
3.5 文档必须可校验、可迁移
所有文档都必须带 schemaVersion,反序列化时先校验,再按版本逐步迁移。文档 JSON 中不能保存函数、DOM 节点或运行时对象。
4. 包职责
4.1 report-core
- 报表文档模型。
- 页面、区域、元素和样式模型。
- 数据源、数据集、参数契约。
- 插件契约和注册表。
- 文档创建、克隆、规范化。
- 序列化、反序列化、校验和迁移。
4.2 report-expression
- 表达式词法和语法解析。
- AST 和受限求值器。
- 字段、参数、行、分组和页面作用域。
- 表达式错误和诊断信息。
禁止通过 eval 或 new Function 执行任意 JavaScript。
4.3 report-layout
- 元素尺寸测量。
- 文本换行和动态高度。
- 明细数据展开。
- 分页和分页诊断。
- 生成统一的 LayoutDocument。
Layout 通过 TextMeasurer、FontResolver 等接口获得环境能力,不直接依赖 DOM。
4.4 report-renderer
- 消费 LayoutDocument。
- 提供 HTML、SVG 和 PDF Renderer。
- 处理目标格式相关的颜色、字体和资源输出。
PDF 的具体实现可以运行在浏览器或 Node/服务端,但必须遵循统一的 Renderer 接口。
4.5 report-designer
- 画布和坐标转换。
- 选择、拖拽、缩放、对齐和吸附。
- 属性编辑。
- 复制粘贴。
- 命令、事务和撤销重做。
- 快捷键和编辑器状态。
Designer 通过 Core 的命令接口修改文档,不直接实现报表业务规则。
4.6 report-designer-vue
只负责 Vue 组件、面板和 UI 适配,不向 Core 泄漏 Vue 类型。
5. report-core 目录
packages/report-core/
│
├── src/
│ ├── model/
│ │ ├── report.ts
│ │ ├── page.ts
│ │ ├── section.ts
│ │ ├── element.ts
│ │ ├── table.ts
│ │ ├── style.ts
│ │ ├── dataset.ts
│ │ ├── datasource.ts
│ │ ├── parameter.ts
│ │ └── common.ts
│ │
│ ├── plugin/
│ │ ├── plugin.ts
│ │ ├── context.ts
│ │ ├── registry.ts
│ │ ├── element-plugin.ts
│ │ ├── datasource-plugin.ts
│ │ └── exporter-plugin.ts
│ │
│ ├── document/
│ │ ├── create.ts
│ │ ├── clone.ts
│ │ └── normalize.ts
│ │
│ ├── serialization/
│ │ ├── serialize.ts
│ │ ├── deserialize.ts
│ │ └── migrate.ts
│ │
│ ├── schema/
│ │ ├── version.ts
│ │ ├── defaults.ts
│ │ └── validation.ts
│ │
│ ├── diagnostics/
│ │ └── diagnostic.ts
│ ├── utils/
│ │ ├── id.ts
│ │ └── deep.ts
│ └── index.ts
│
└── tests/
6. Report 文档模型
报表采用“文档 + 区域 + 元素树”模型,不把所有元素平铺在根节点上。
export interface ReportDocument {
schemaVersion: number
id: string
name: string
page: PageSettings
styles: Record<string, StyleDefinition>
dataSources: Record<string, DataSourceDefinition>
datasets: Record<string, DatasetDefinition>
parameters: Record<string, ReportParameter>
sections: ReportSection[]
plugins?: ReportPluginReference[]
metadata?: ReportMetadata
}
6.1 报表区域
export type ReportSectionKind =
| "report-header"
| "page-header"
| "detail"
| "group-header"
| "group-footer"
| "summary"
| "page-footer"
export interface ReportSection {
id: string
kind: ReportSectionKind
datasetId?: string
children: ReportElement[]
options?: {
repeatOnEachPage?: boolean
keepTogether?: boolean
allowSplit?: boolean
}
}
V1 先实现 page-header、detail 和 page-footer,其他区域只保留模型扩展点。
实现进度:group-header / group-footer 已可用(含多级分组与组内聚合)。 report-header 与 summary 仍只保留模型扩展点。
分组区域通过
groupExpression指定分组键,groupLevel指定嵌套级别:export interface ReportSection { // ...原有字段 /** 分组键表达式,仅 group-header / group-footer 使用。 */ groupExpression?: string /** 分组级别,从 1 开始;数值小的为外层组。 */ groupLevel?: number }分组语义为「相邻行键值相同即同组」,不改变行顺序—— 因此宿主应先按分组键排序再传入数据。组脚可通过
group作用域访问 聚合能力,如sum(group.amount)、group.count、group.key。
6.2 元素树
export interface ReportElement {
id: string
type: string
frame: Rect
styleId?: string
binding?: ValueBinding
props?: Record<string, unknown>
children?: ReportElement[]
}
export interface Rect {
x: number
y: number
width: number
height: number
}
export interface ValueBinding {
field?: string
expression?: string
format?: string
}
内置元素初期包括:
text
image
line
rect
container
table
插件元素使用自定义 type,并通过插件 Schema 验证 props。
6.3 明细表
明细表是 V1 的特殊元素,负责把数据集展开成多行。单元格内的元素坐标相对于当前单元格。
export interface TableElement extends ReportElement {
type: "table"
datasetId: string
columns: TableColumn[]
repeatHeaderOnEachPage?: boolean
}
export interface TableColumn {
id: string
width: number
header?: string
binding?: ValueBinding
children?: ReportElement[]
}
6.4 样式
样式通过 ID 复用,并允许有限的继承和元素级覆盖:
export interface StyleDefinition {
id: string
extends?: string
fontFamily?: string
fontSize?: number
color?: string
background?: string
border?: BorderStyle
align?: "left" | "center" | "right"
verticalAlign?: "top" | "middle" | "bottom"
}
7. Page 模型
V1 文档内部统一使用 mm,px 只用于 Designer 显示层,pt 只在 PDF 等输出边界转换。
export interface PageSettings {
paper: "A4" | "A5" | "custom"
orientation: "portrait" | "landscape"
customSize?: {
width: number
height: number
}
margin: PageMargin
background?: string
}
export interface PageMargin {
top: number
right: number
bottom: number
left: number
}
paper 和 orientation 是页面配置来源;Layout 阶段将其解析为最终的 width、height。自定义纸张必须提供尺寸,尺寸均以 mm 表示。
8. 数据和运行时上下文
报表文档只保存数据源定义,不保存连接、Token 和本次查询结果。
export interface DataSourceDefinition {
id: string
type: string
config?: Record<string, unknown>
connectionRef?: string
}
export interface DatasetDefinition {
id: string
sourceId?: string
fields?: DatasetField[]
query?: string
}
export interface RenderContext {
data: Record<string, unknown>
parameters: Record<string, unknown>
locale?: string
timezone?: string
}
export interface DataProvider {
getDataset(
dataset: DatasetDefinition,
context: RenderContext
): Promise<ReadonlyArray<Record<string, unknown>>>
}
DataProvider 由宿主应用注入,Core 不主动访问网络或数据库。 config 只保存非敏感的设计配置;连接密码、Token 等敏感信息必须通过 connectionRef 或宿主运行时注入。
9. Layout 中间结果
Layout Engine 接受报表文档和运行时上下文,输出所有 Renderer 共用的分页结果。
export interface LayoutOptions {
dataProvider?: DataProvider
textMeasurer: TextMeasurer
fontResolver: FontResolver
}
export interface LayoutEngine {
layout(
document: ReportDocument,
context: RenderContext,
options?: LayoutOptions
): Promise<LayoutDocument>
}
export interface LayoutDocument {
pages: LayoutPage[]
diagnostics: LayoutDiagnostic[]
}
export interface LayoutPage {
width: number
height: number
nodes: LayoutNode[]
}
export interface LayoutNode {
id: string
type: string
frame: Rect
style: ResolvedStyle
content?: unknown
children?: LayoutNode[]
}
Layout 必须支持以下接口:
export interface TextMeasurer {
measure(text: string, style: ResolvedStyle): TextMetrics
}
export interface FontResolver {
resolve(fontFamily: string): ResolvedFont
}
这样可以分别适配浏览器、Node 和服务端字体环境,同时让分页逻辑保持独立。
10. 表达式约定
表达式系统使用受限语法和 AST,不执行任意 JavaScript。
V1 至少支持:
字段:row.amount
参数:params.customerName
简单运算:row.price * row.quantity
条件:if(row.amount > 0, "有", "无")
格式化:format(row.date, "YYYY-MM-DD")
表达式失败时返回带有元素 ID 和表达式内容的诊断信息,单个字段错误不应导致整个文档无结果。
11. 插件模型
插件只通过契约接入,不允许 Core 直接导入插件实现。
export interface ReportPlugin {
id: string
version: string
elements?: ElementPlugin[]
datasources?: DataSourcePlugin[]
exporters?: ExporterPlugin[]
}
export interface ElementPlugin {
type: string
schema: unknown
createDefault(): ReportElement
}
需要针对不同运行环境提供可选适配器:
ElementPlugin
├── LayoutAdapter
├── RendererAdapter(html/svg/pdf)
└── DesignerAdapter
插件引用只保存 id、version 和配置。插件缺失时应产生诊断信息,而不是阻止整个文档被读取。
12. 序列化、校验和迁移
- schemaVersion 使用单调递增整数。
- 反序列化顺序为:解析 JSON → 校验结构 → 执行迁移 → 规范化默认值。
- 迁移函数按版本逐步执行,不允许跨版本直接修改内部对象。
- 序列化结果保持稳定排序,便于版本控制和差异比较。
- 校验错误必须指出字段路径和元素 ID。
示例:
deserialize(json)
→ validate(json)
→ migrate(json, currentVersion)
→ normalize(document)
13. 诊断和错误处理
统一使用诊断对象表达警告和错误:
export interface Diagnostic {
severity: "info" | "warning" | "error"
code: string
message: string
path?: string
elementId?: string
}
数据缺失、表达式错误、字体缺失、插件缺失和分页失败都应尽量返回可定位的诊断信息。
14. UI 规范
14.1 设计目标
Designer 的界面采用现代、简洁、干净的工具型产品风格,参考 shadcn/ui 的设计语言,但不要求 Core 或业务应用必须绑定某一个 UI 框架。
核心原则:
- 画布优先,减少非必要装饰。
- 使用中性背景、细边框和低强度阴影建立层次。
- 通过间距、字号和颜色层级表达信息,不依赖渐变和大面积高亮。
- 常用操作保持短路径,复杂配置分组收起。
- 所有颜色、间距、圆角和阴影使用设计令牌,禁止组件内散落硬编码。
14.2 UI 层边界
- report-core 不包含任何 UI、CSS 或主题代码。
- report-designer 负责编辑器状态、命令和交互行为。
- report-designer-vue 负责 Vue 组件、面板和样式实现。
- 组件采用 Headless 思路,宿主应用可以覆盖主题和品牌色。
- 可以使用 shadcn-vue 或同类组件实现,但必须通过 report-designer-vue 隔离依赖。
14.3 整体布局
桌面端采用三栏、画布居中的工作区:
┌──────────────────────────────────────────────────────────────┐
│ 顶部工具栏:文件、撤销、重做、预览、保存、缩放、主题 │
├──────────────┬───────────────────────────────┬───────────────┤
│ 左侧工具箱 │ │ 右侧属性面板 │
│ 元素 / 数据 │ 报表画布 │ 属性 / 样式 │
│ 模板 / 图层 │ │ 数据绑定 │
├──────────────┴───────────────────────────────┴───────────────┤
│ 底部状态栏:页码、缩放、单位、布局诊断、快捷键提示 │
└──────────────────────────────────────────────────────────────┘
建议尺寸:
- 左侧工具箱:240px 左右,可折叠。
- 右侧属性面板:320px 左右,可折叠。
- 顶部工具栏:48px。
- 底部状态栏:28px。
- 中间画布区域自适应,并支持拖拽调整两侧面板宽度。
屏幕宽度小于 900px 时,侧栏改为抽屉或浮层;移动端优先保证预览和基础属性编辑,不强行复刻完整桌面编辑器。
14.4 设计令牌
使用 CSS 自定义属性作为主题契约,至少提供亮色和暗色两套主题:
:root {
--rd-background: 0 0% 100%;
--rd-foreground: 222 47% 11%;
--rd-muted: 210 40% 96%;
--rd-muted-foreground: 215 16% 47%;
--rd-border: 214 32% 91%;
--rd-input: 214 32% 91%;
--rd-primary: 221 83% 53%;
--rd-primary-foreground: 210 40% 98%;
--rd-destructive: 0 72% 51%;
--rd-canvas: 220 14% 96%;
--rd-selection: 221 83% 53%;
--rd-radius: 8px;
}
具体色值可以由宿主应用覆盖,但必须保持以下语义:
- background:应用和面板背景。
- canvas:画布工作区背景。
- foreground:主要文字和图标。
- muted:次级区域和禁用背景。
- border:分隔线和输入框边框。
- primary:主操作、选中态和焦点环。
- destructive:删除和不可逆操作。
- selection:画布元素选中框和辅助线。
推荐使用 OKLCH 或 HSL 变量,不在组件中直接写十六进制颜色。
14.5 字体和间距
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC", "Microsoft YaHei", sans-serif;
推荐字号:12px(辅助信息)、13px(表单和工具栏)、14px(正文)、16px(小标题)、20px(页面标题)。
间距使用 4px 基准:4、8、12、16、20、24、32px。默认圆角 6~8px,面板阴影保持低强度,避免卡片堆叠感。
14.6 基础组件风格
基础组件遵循扁平、克制、可聚焦的风格:
- Button:Primary、Secondary、Ghost、Destructive、Icon 五类。
- Input、Textarea、Select、Combobox:1px 边框,聚焦时显示明显但克制的 focus ring。
- Tabs、Segmented Control:用于属性面板和视图切换。
- Popover、Tooltip、Context Menu、Command Menu:用于低频和快捷操作。
- Dialog、Toast、Alert:用于确认、反馈和错误提示。
- Resizable Panel:用于左右侧栏和属性面板。
按钮不使用过度圆润的胶囊形,图标按钮必须提供 Tooltip 和可访问名称。删除等危险操作不能只依赖颜色区分。
14.7 画布规范
- 工作区使用浅灰或深灰中性背景,页面使用白色或主题纸张颜色。
- 页面保持轻微阴影和清晰边界,不使用强烈投影。
- 元素 Hover 使用低透明度背景,Selected 使用 1px 主色边框和 8 个缩放控制点。
- 对齐线、吸附线和标尺使用辅助色,不遮挡元素内容。
- 支持 50%、75%、100%、125%、150%、200% 常用缩放。
- 画布操作同时支持鼠标、键盘和上下文菜单,不把拖拽作为唯一入口。
- 选中态、错误态和辅助线不能只通过颜色表达,必要时增加边框、图标或文字提示。
14.8 属性面板规范
- 按“布局、内容、样式、数据绑定、高级”分组。
- 分组默认折叠低频配置,保留最近使用状态。
- 数值输入显示单位,支持键盘微调和批量修改。
- 绑定字段提供字段选择器,同时允许直接输入表达式。
- 无选中元素时显示页面属性;多选时只显示共同属性。
- 表单错误就地显示,并关联到对应字段,不使用只在底部出现的泛化错误。
14.9 交互和状态
顶部工具栏至少提供:
- 新建、打开、保存。
- 撤销、重做。
- 预览、打印。
- 缩放和适应页面。
- 主题切换和帮助入口。
推荐快捷键:Ctrl/Cmd + S 保存、Ctrl/Cmd + Z 撤销、Ctrl/Cmd + Shift + Z 重做、Delete 删除、Ctrl/Cmd + C/V 复制粘贴、Ctrl/Cmd + K 打开命令菜单。
所有异步操作都要有 Loading、Empty、Error 和 Disabled 状态;保存成功、布局警告和表达式错误使用统一 Toast 或诊断面板反馈。
14.10 主题、密度和无障碍
- 默认提供 Light 和 Dark 两套主题。
- 支持 Standard 和 Compact 两种密度,默认 Standard。
- 颜色对比度目标达到 WCAG AA,正文至少 4.5:1。
- 所有交互控件支持键盘访问,焦点状态必须可见。
- 弹窗、抽屉、菜单和属性面板提供正确的 ARIA 语义。
- 点击目标不小于 32px,主要触控目标建议不小于 36px。
- 尊重 prefers-reduced-motion,避免非必要动画。
14.11 UI 验收标准
初期 UI 至少应满足:
- 用户可以在不阅读文档的情况下找到新增元素、保存、预览和撤销操作。
- 画布、工具箱和属性面板在亮色和暗色主题下都能正常使用。
- 选中、禁用、错误、加载和空状态具有一致的视觉语言。
- 关键操作可以只使用键盘完成。
- 主题颜色可以由宿主应用通过 CSS 变量覆盖。
15. 测试策略
初期至少建立以下测试:
- √ 文档 Schema、默认值和版本迁移测试。
- √ 固定元素和明细表的布局快照测试。
- √ 页眉、页脚和多页分页测试。
- HTML、SVG、PDF Renderer 的公共布局结果一致性测试。
- √ 表达式安全性、空值和类型转换测试。
- √ 中文字体、长文本和自定义纸张测试。
- √ 插件缺失和插件版本不兼容测试。
建议准备一个固定的示例报表作为跨包契约测试数据。
当前测试规模:
report-core139 例、report-expression111 例、report-layout124 例、report-renderer12 例、report-designer14 例、report-designer-vue11 例,合计 411 例。 字体相关用例依赖系统字体,在缺少字体的环境中自动跳过。 HTML/SVG 分页一致性测试已随 Renderer 实现补齐。
16. 初期实施顺序
进度标记说明:
√表示已实现并有测试覆盖;◐表示部分实现;未标记表示尚未开始。当前进度:阶段 1、2、3 已完成;阶段 4 主体完成(Renderer、Designer 核心、 Vue UI 骨架),Vue 层的图层面板、数据绑定面板、快捷键留待后续迭代。
√ 阶段 1:Core 和文档模型
- √ 完成 ReportDocument、Section、Element、Page、Style。
- √ 完成默认值、校验、序列化和迁移。
- √ 完成基础命令和 ID 生成。
产物:packages/report-core(139 个测试用例)。
√ 阶段 2:基础 Layout
- √ 完成单页固定元素布局。
- √ 完成文本测量、换行和基础分页。
- √ 输出统一的 LayoutDocument。
产物:packages/report-layout(含自研 TTF/TTC 字体度量解析)。
√ 阶段 3:数据和明细表
- √ 接入 RenderContext 和 DataProvider。
- √ 支持字段绑定、简单表达式和明细表展开。
- √ 支持页眉、页脚重复。
产物:packages/report-expression(83+28 个测试用例)、packages/report-layout 的
数据绑定与分组能力。
补充实现(超出本阶段原始列项):
- √ 受限表达式系统(字段、参数、运算、条件、格式化)。
- √ 聚合函数(sum / avg / count / min / max)。
- √ 基础分组(group-header / group-footer、多级分组、组内聚合)。
- √ 明细表表头每页重复。
◐ 阶段 4:Renderer 和 Designer
- √ 实现 SVG、HTML 和浏览器打印(
@report/renderer,含 HTML/SVG 分页一致性测试)。 - √ 实现 PDF Renderer 契约(可注入
PdfBackend,缺失/报错走诊断)。 - √ 完成 Designer 的选择、拖拽、属性编辑和撤销重做(
@report/designer,纯逻辑层)。 - √ Vue 层 UI 骨架(
@report/designer-vue,原生 Vue3 + Vite 库模式,无第三方组件库): 三栏布局壳、画布(渲染/选中/新增)、工具栏(撤销重做/缩放)、属性面板(基础编辑)、 状态栏、设计令牌(亮/暗主题 CSS 变量)。 - 待后续迭代:图层面板、数据绑定面板、拖拽移动、快捷键、预览接入 Renderer。
阶段 5:插件和 PDF
- 实现二维码、条码等独立插件。
- 根据部署环境确定 PDF 的具体后端。
- 补充字体、资源和输出一致性测试。
17. V1 验收标准
一个示例报表应当能够:
- 在 Designer 中创建并保存。
- 绑定一组运行时数据。
- 生成包含页眉、明细和页脚的多页结果。
- 在 HTML 和 SVG 中得到相同的分页结构。
- 通过浏览器打印输出。
- 在升级 Schema 后仍可被迁移和读取。
达到以上标准后,再继续扩展复杂分组、图表、PDF 和更多插件。