@veltra/sheet 2.3.3 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/report/binding-badge.js +23 -5
- package/dist/components/report/designer/drill-dialog.js +258 -0
- package/dist/components/report/designer/float-panel.js +26 -14
- package/dist/components/report/print.js +39 -0
- package/dist/components/report/report-designer.js +56 -21
- package/dist/components/report/report-viewer.js +173 -44
- package/dist/components/report/style2.css +91 -0
- package/dist/components/report/use-report-designer.js +2 -1
- package/dist/components/report/use-report-viewer.js +88 -3
- package/dist/components/sheet/insert-image.js +1 -1
- package/dist/components/sheet/popups/import.worker.js +2 -3
- package/dist/components/sheet/use-sheet-state.js +2 -2
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -2
- package/dist/report/drill.d.ts +48 -0
- package/dist/report/drill.js +78 -0
- package/dist/report/export-xlsx.d.ts +3 -7
- package/dist/report/export-xlsx.js +6 -85
- package/dist/report/index.d.ts +5 -3
- package/dist/report/print.d.ts +23 -0
- package/dist/report/print.js +197 -0
- package/dist/report/render/aggregate.js +25 -1
- package/dist/report/types.d.ts +19 -1
- package/dist/tools/download.js +2 -2
- package/dist/types/report-designer.d.ts +13 -0
- package/dist/types/report-viewer.d.ts +19 -0
- package/package.json +6 -9
- package/src/components/report/__test__/binding-badge.test.ts +92 -0
- package/src/components/report/__test__/report-designer.test.ts +98 -3
- package/src/components/report/__test__/report-drill.test.ts +692 -0
- package/src/components/report/__test__/report-viewer.test.ts +43 -0
- package/src/components/report/__test__/use-report-designer.test.ts +108 -1
- package/src/components/report/binding-badge.ts +35 -5
- package/src/components/report/designer/drill-dialog.vue +245 -0
- package/src/components/report/designer/float-panel.vue +12 -0
- package/src/components/report/print.ts +44 -0
- package/src/components/report/report-designer.vue +35 -2
- package/src/components/report/report-viewer.vue +171 -18
- package/src/components/report/style-designer.scss +66 -0
- package/src/components/report/style-viewer.scss +40 -0
- package/src/components/report/use-report-designer.ts +8 -1
- package/src/components/report/use-report-viewer.ts +150 -11
- package/src/components/sheet/insert-image.ts +1 -1
- package/src/components/sheet/popups/export-popup.vue +1 -1
- package/src/components/sheet/popups/import.worker.ts +7 -10
- package/src/components/sheet/use-sheet-state.ts +2 -2
- package/src/report/__test__/drill.test.ts +194 -0
- package/src/report/__test__/export-xlsx.test.ts +3 -25
- package/src/report/__test__/print.test.ts +170 -0
- package/src/report/__test__/render.test.ts +26 -0
- package/src/report/drill.ts +112 -0
- package/src/report/export-xlsx.ts +5 -103
- package/src/report/index.ts +4 -1
- package/src/report/print.ts +234 -0
- package/src/report/render/aggregate.ts +28 -0
- package/src/report/types.ts +20 -0
- package/src/tools/__test__/builtin.test.ts +1 -1
- package/src/tools/download.ts +2 -2
- package/src/tools/export.worker.ts +2 -2
- package/src/types/report-designer.ts +18 -1
- package/src/types/report-viewer.ts +18 -1
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/// <reference lib="webworker" />
|
|
2
2
|
/**
|
|
3
|
-
* XLSX 解析 Worker:
|
|
3
|
+
* XLSX 解析 Worker:xlsx 解析 + 模型构建(大文件 3~4s 同步重活)
|
|
4
4
|
* 移到独立线程,主线程保持空闲(loading 动画正常、交互不冻结)。
|
|
5
5
|
*
|
|
6
6
|
* 协议:主线程 postMessage({ buffer: ArrayBuffer }) → worker 返回
|
|
7
|
-
* - { type: 'progress', done, total }(模型分片构建进度,
|
|
7
|
+
* - { type: 'progress', done, total }(模型分片构建进度,xlsx 解析段无进度)
|
|
8
8
|
* - { type: 'done', ok: true, sheets: [{ name, snapshot }], activeIndex }(快照为
|
|
9
9
|
* 纯数据,可结构化克隆);失败 { type: 'done', ok: false, error }。
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* 解析与构建经 sheet-core importXlsx 完成(hucre 仅由 sheet-core 依赖);
|
|
12
|
+
* 名字唯一化与活动表对齐在 worker 内完成,主线程直接把快照替换进目标
|
|
13
|
+
* (无 undo 历史——替换语义由确认后的 replaceWorkbookWithSnapshots 负责)。
|
|
13
14
|
*
|
|
14
15
|
* 注意:必须用**运行时动态 import**——worker 顶层静态 import 在 vite dev 的
|
|
15
16
|
* worker 上下文中会因模块图加载顺序导致 `Workbook is not defined`(实测)。
|
|
@@ -30,15 +31,11 @@ self.onmessage = (e: MessageEvent<{ buffer: ArrayBuffer }>): void => {
|
|
|
30
31
|
const { buffer } = e.data
|
|
31
32
|
void (async () => {
|
|
32
33
|
try {
|
|
33
|
-
const
|
|
34
|
-
import('hucre/xlsx'),
|
|
35
|
-
import('@veltra/sheet-core/core/io/import')
|
|
36
|
-
])
|
|
37
|
-
const hucreWb = await readXlsx(new Uint8Array(buffer), XLSX_READ_OPTIONS)
|
|
34
|
+
const { importXlsx } = await import('@veltra/sheet-core/core/io/import')
|
|
38
35
|
// 分片构建:按 10% 粒度回报进度(避免 196 条/秒的消息风暴与文字跳变过快;
|
|
39
36
|
// 模型构建段约 1s,10 次更新肉眼可见数字推进)
|
|
40
37
|
let lastPercent = -1
|
|
41
|
-
const wb =
|
|
38
|
+
const wb = await importXlsx(new Uint8Array(buffer), (done, total) => {
|
|
42
39
|
const percent = total > 0 ? Math.floor((done / total) * 10) : 10
|
|
43
40
|
if (percent !== lastPercent || done === total) {
|
|
44
41
|
lastPercent = percent
|
|
@@ -123,8 +123,8 @@ export function useSheetState(props: SheetProps, emit: Emit, hooks: SheetStateHo
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
|
-
* 工作簿内容被结构性替换后同步(如导入 xlsx 的
|
|
127
|
-
*
|
|
126
|
+
* 工作簿内容被结构性替换后同步(如导入 xlsx 的 replaceWorkbookWithSnapshots):
|
|
127
|
+
* 整簿替换未必触发 active-sheet-change(同 index),需显式同步 tabs、
|
|
128
128
|
* 重绑 sheet 事件并重建网格。
|
|
129
129
|
*/
|
|
130
130
|
function syncFromWorkbook(): void {
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Sheet, cellKey } from '@veltra/sheet-core'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { REPORT_META_NAMESPACE } from '../binding'
|
|
5
|
+
import {
|
|
6
|
+
buildDrillHitMap,
|
|
7
|
+
createDrillStack,
|
|
8
|
+
currentDrillLayer,
|
|
9
|
+
popDrillLayer,
|
|
10
|
+
pushDrillLayer,
|
|
11
|
+
resolveDrillParams
|
|
12
|
+
} from '../drill'
|
|
13
|
+
import { createReportTemplate, type ReportTemplate } from '../template'
|
|
14
|
+
import type { ReportBinding, ReportDrillConfig } from '../types'
|
|
15
|
+
|
|
16
|
+
// ---- 内联 fixtures ----
|
|
17
|
+
|
|
18
|
+
/** 最小 Report Template:下钻栈只关心引用与参数,不消费快照内容 */
|
|
19
|
+
function makeTemplate(rows: number): ReportTemplate {
|
|
20
|
+
return {
|
|
21
|
+
version: 1,
|
|
22
|
+
cells: [],
|
|
23
|
+
styles: [],
|
|
24
|
+
merges: [],
|
|
25
|
+
frozen: { rows: 0, cols: 0 },
|
|
26
|
+
rows,
|
|
27
|
+
cols: 0
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TPL_A = makeTemplate(10)
|
|
32
|
+
const TPL_B = makeTemplate(20)
|
|
33
|
+
const TPL_C = makeTemplate(30)
|
|
34
|
+
|
|
35
|
+
const DRILL: ReportDrillConfig = {
|
|
36
|
+
target: 'tpl-detail',
|
|
37
|
+
mapping: { region: 'p_region', amount: 'p_amount' },
|
|
38
|
+
openMode: 'switch'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('resolveDrillParams', () => {
|
|
42
|
+
it('按映射从记录取值为详情报参数', () => {
|
|
43
|
+
const values = resolveDrillParams(DRILL, { region: '华东', amount: 100 })
|
|
44
|
+
expect(values).toEqual({ p_region: '华东', p_amount: 100 })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('记录缺字段时跳过对应参数,null 值保留', () => {
|
|
48
|
+
const values = resolveDrillParams(DRILL, { region: '华东', amount: null })
|
|
49
|
+
expect(values).toEqual({ p_region: '华东', p_amount: null })
|
|
50
|
+
expect(
|
|
51
|
+
'p_missing' in resolveDrillParams({ ...DRILL, mapping: { missing: 'p_missing' } }, {})
|
|
52
|
+
).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('空映射返回空参数', () => {
|
|
56
|
+
expect(resolveDrillParams({ ...DRILL, mapping: {} }, { region: '华东' })).toEqual({})
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('下钻栈', () => {
|
|
61
|
+
it('根层即当前层', () => {
|
|
62
|
+
const stack = createDrillStack({ template: TPL_A, params: { p: 'a0' } })
|
|
63
|
+
expect(stack).toHaveLength(1)
|
|
64
|
+
expect(currentDrillLayer(stack)).toEqual({ template: TPL_A, params: { p: 'a0' } })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('push 后当前层为新层,前层参数定格为离开时的值', () => {
|
|
68
|
+
const stack = createDrillStack({ template: TPL_A, params: { p: 'a0' } })
|
|
69
|
+
const next = pushDrillLayer(stack, { template: TPL_B, params: { p: 'b1' } }, { p: 'a1' })
|
|
70
|
+
expect(next).toHaveLength(2)
|
|
71
|
+
expect(currentDrillLayer(next)).toEqual({ template: TPL_B, params: { p: 'b1' } })
|
|
72
|
+
expect(next[0]).toEqual({ template: TPL_A, params: { p: 'a1' } })
|
|
73
|
+
// 不可变更新:原栈不受影响
|
|
74
|
+
expect(stack).toHaveLength(1)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('push 缺省 currentParams 时保留前层原参数', () => {
|
|
78
|
+
const stack = createDrillStack({ template: TPL_A, params: { p: 'a0' } })
|
|
79
|
+
const next = pushDrillLayer(stack, { template: TPL_B, params: {} })
|
|
80
|
+
expect(next[0]).toEqual({ template: TPL_A, params: { p: 'a0' } })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('pop 逐级回退并保留各层当时参数', () => {
|
|
84
|
+
let stack = createDrillStack({ template: TPL_A, params: { p: 'a0' } })
|
|
85
|
+
stack = pushDrillLayer(stack, { template: TPL_B, params: { p: 'b1' } }, { p: 'a1' })
|
|
86
|
+
stack = pushDrillLayer(stack, { template: TPL_C, params: { p: 'c1' } }, { p: 'b2' })
|
|
87
|
+
|
|
88
|
+
stack = popDrillLayer(stack)
|
|
89
|
+
expect(currentDrillLayer(stack)).toEqual({ template: TPL_B, params: { p: 'b2' } })
|
|
90
|
+
stack = popDrillLayer(stack)
|
|
91
|
+
expect(currentDrillLayer(stack)).toEqual({ template: TPL_A, params: { p: 'a1' } })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('仅剩根层时 pop 原样返回', () => {
|
|
95
|
+
const stack = createDrillStack({ template: TPL_A, params: {} })
|
|
96
|
+
expect(popDrillLayer(stack)).toBe(stack)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('指向自身照常压栈,靠 pop 逐级退出', () => {
|
|
100
|
+
let stack = createDrillStack({ template: TPL_A, params: { p: 'a0' } })
|
|
101
|
+
stack = pushDrillLayer(stack, { template: TPL_A, params: { p: 'a1' } })
|
|
102
|
+
stack = pushDrillLayer(stack, { template: TPL_A, params: { p: 'a2' } })
|
|
103
|
+
expect(stack).toHaveLength(3)
|
|
104
|
+
|
|
105
|
+
stack = popDrillLayer(stack)
|
|
106
|
+
expect(currentDrillLayer(stack)).toEqual({ template: TPL_A, params: { p: 'a1' } })
|
|
107
|
+
stack = popDrillLayer(stack)
|
|
108
|
+
expect(currentDrillLayer(stack)).toEqual({ template: TPL_A, params: { p: 'a0' } })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('成环照常压栈,回退沿压栈路径逐级退出', () => {
|
|
112
|
+
let stack = createDrillStack({ template: TPL_A, params: { p: 'root' } })
|
|
113
|
+
stack = pushDrillLayer(stack, { template: TPL_B, params: { p: 'b1' } })
|
|
114
|
+
stack = pushDrillLayer(stack, { template: TPL_A, params: { p: 'a-from-b' } })
|
|
115
|
+
stack = pushDrillLayer(stack, { template: TPL_B, params: { p: 'b2' } })
|
|
116
|
+
expect(stack.map((layer) => layer.template)).toEqual([TPL_A, TPL_B, TPL_A, TPL_B])
|
|
117
|
+
|
|
118
|
+
stack = popDrillLayer(stack)
|
|
119
|
+
expect(currentDrillLayer(stack)).toEqual({ template: TPL_A, params: { p: 'a-from-b' } })
|
|
120
|
+
stack = popDrillLayer(stack)
|
|
121
|
+
expect(currentDrillLayer(stack)).toEqual({ template: TPL_B, params: { p: 'b1' } })
|
|
122
|
+
stack = popDrillLayer(stack)
|
|
123
|
+
expect(currentDrillLayer(stack)).toEqual({ template: TPL_A, params: { p: 'root' } })
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('buildDrillHitMap 与上下文解析', () => {
|
|
128
|
+
it('分组格与汇总聚合格均生成正确命中上下文(含汇总格自身聚合值)', () => {
|
|
129
|
+
const sheet = new Sheet()
|
|
130
|
+
sheet.setCellMeta({ row: 0, col: 0 }, REPORT_META_NAMESPACE, {
|
|
131
|
+
dataset: 'ds',
|
|
132
|
+
field: 'region',
|
|
133
|
+
aggregate: 'group',
|
|
134
|
+
expand: 'down',
|
|
135
|
+
drill: { target: 't2', mapping: { region: 'p_region' }, openMode: 'switch' }
|
|
136
|
+
})
|
|
137
|
+
sheet.setCellMeta({ row: 0, col: 1 }, REPORT_META_NAMESPACE, {
|
|
138
|
+
dataset: 'ds',
|
|
139
|
+
field: 'amount',
|
|
140
|
+
aggregate: 'sum',
|
|
141
|
+
expand: 'none',
|
|
142
|
+
rowParent: { row: 0, col: 0 },
|
|
143
|
+
drill: {
|
|
144
|
+
target: 't2',
|
|
145
|
+
mapping: { region: 'p_region', amount: 'p_amount' },
|
|
146
|
+
openMode: 'dialog'
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const template = sheet.snapshot()
|
|
151
|
+
const data = {
|
|
152
|
+
ds: [
|
|
153
|
+
{ region: '华东', amount: 100 },
|
|
154
|
+
{ region: '华东', amount: 200 },
|
|
155
|
+
{ region: '华南', amount: 50 }
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const hitMap = buildDrillHitMap(template, data)
|
|
160
|
+
// 华东分组格 (0,0)
|
|
161
|
+
const hitGroup0 = hitMap.get(cellKey({ row: 0, col: 0 }))
|
|
162
|
+
expect(hitGroup0?.record).toEqual({ region: '华东' })
|
|
163
|
+
|
|
164
|
+
// 华东汇总格 (0,1) 拥有祖先分组 region 与自身的求和计算值 300
|
|
165
|
+
const hitSum0 = hitMap.get(cellKey({ row: 0, col: 1 }))
|
|
166
|
+
expect(hitSum0?.record).toEqual({ region: '华东', amount: 300 })
|
|
167
|
+
expect(resolveDrillParams(hitSum0!.config, hitSum0!.record)).toEqual({
|
|
168
|
+
p_region: '华东',
|
|
169
|
+
p_amount: 300
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// 华南汇总格 (1,1)
|
|
173
|
+
const hitSum1 = hitMap.get(cellKey({ row: 1, col: 1 }))
|
|
174
|
+
expect(hitSum1?.record).toEqual({ region: '华南', amount: 50 })
|
|
175
|
+
expect(resolveDrillParams(hitSum1!.config, hitSum1!.record)).toEqual({
|
|
176
|
+
p_region: '华南',
|
|
177
|
+
p_amount: 50
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('旧绑定兼容', () => {
|
|
183
|
+
it('无 drill 字段的旧绑定序列化往返后不出现 drill 键', () => {
|
|
184
|
+
const binding: ReportBinding = {
|
|
185
|
+
dataset: 'orders',
|
|
186
|
+
field: 'region',
|
|
187
|
+
expand: 'down',
|
|
188
|
+
aggregate: 'list'
|
|
189
|
+
}
|
|
190
|
+
const restored = JSON.parse(JSON.stringify(binding)) as ReportBinding
|
|
191
|
+
expect('drill' in restored).toBe(false)
|
|
192
|
+
expect(restored.drill).toBeUndefined()
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { Sheet } from '@veltra/sheet-core'
|
|
2
|
-
import {
|
|
3
|
-
import { readXlsx } from 'hucre/xlsx'
|
|
2
|
+
import { importXlsx } from '@veltra/sheet-core/core/io/import'
|
|
4
3
|
import { describe, expect, it } from 'vitest'
|
|
5
4
|
|
|
6
5
|
import { REPORT_META_NAMESPACE, createReportBinding } from '../binding'
|
|
7
|
-
import {
|
|
6
|
+
import { exportFilledReportXlsx } from '../export-xlsx'
|
|
8
7
|
import { renderReport } from '../render'
|
|
9
8
|
import type { DatasetCatalogItem } from '../types'
|
|
10
9
|
|
|
@@ -69,8 +68,7 @@ describe('exportFilledReportXlsx', () => {
|
|
|
69
68
|
expect(buffer[0]).toBe(0x50)
|
|
70
69
|
expect(buffer[1]).toBe(0x4b)
|
|
71
70
|
|
|
72
|
-
const
|
|
73
|
-
const workbook = buildWorkbookFromHucre(hucreWb)
|
|
71
|
+
const workbook = await importXlsx(buffer)
|
|
74
72
|
const sheet = workbook.getSheets()[0]!
|
|
75
73
|
|
|
76
74
|
// 展开值保真(分组头 + 明细值)
|
|
@@ -96,23 +94,3 @@ describe('exportFilledReportXlsx', () => {
|
|
|
96
94
|
expect(headerStyle?.fill?.color).toBe('#E8EEF7')
|
|
97
95
|
})
|
|
98
96
|
})
|
|
99
|
-
|
|
100
|
-
describe('buildColumnDefs / pxToExcelColWidth', () => {
|
|
101
|
-
it('像素列宽换算为 Excel 字符宽度并写入对应列', () => {
|
|
102
|
-
expect(pxToExcelColWidth(120)).toBe(16)
|
|
103
|
-
expect(pxToExcelColWidth(0)).toBe(1)
|
|
104
|
-
|
|
105
|
-
const columns = buildColumnDefs([
|
|
106
|
-
[0, 120],
|
|
107
|
-
[2, 145]
|
|
108
|
-
])
|
|
109
|
-
expect(columns).toHaveLength(3)
|
|
110
|
-
expect(columns[0]).toEqual({ width: 16 })
|
|
111
|
-
expect(columns[1]).toEqual({})
|
|
112
|
-
expect(columns[2]).toEqual({ width: 20 })
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
it('空列宽不产生 columns 定义', () => {
|
|
116
|
-
expect(buildColumnDefs([])).toEqual([])
|
|
117
|
-
})
|
|
118
|
-
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Sheet } from '@veltra/sheet-core'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { buildFilledReportPrintHtml } from '../print'
|
|
5
|
+
|
|
6
|
+
/** 基础填充表:表头 + 两列数据 */
|
|
7
|
+
function createFilledSheet(name = '销售报表'): Sheet {
|
|
8
|
+
const sheet = new Sheet(name)
|
|
9
|
+
sheet.setCells([
|
|
10
|
+
{ addr: { row: 0, col: 0 }, data: { v: '客户' } },
|
|
11
|
+
{ addr: { row: 0, col: 1 }, data: { v: '金额' } },
|
|
12
|
+
{ addr: { row: 1, col: 0 }, data: { v: '甲公司' } },
|
|
13
|
+
{ addr: { row: 1, col: 1 }, data: { v: 100 } }
|
|
14
|
+
])
|
|
15
|
+
return sheet
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('buildFilledReportPrintHtml', () => {
|
|
19
|
+
it('输出完整打印文档:值为文本、默认 A4 纵向、缺省标题取 sheet 名', () => {
|
|
20
|
+
const html = buildFilledReportPrintHtml(createFilledSheet())
|
|
21
|
+
|
|
22
|
+
expect(html).toContain('<!doctype html>')
|
|
23
|
+
expect(html).toContain('<title>销售报表</title>')
|
|
24
|
+
expect(html).toContain('@page { size: A4 portrait;')
|
|
25
|
+
expect(html).toContain('<td>客户</td>')
|
|
26
|
+
expect(html).toContain('<td>100</td>')
|
|
27
|
+
// 默认列宽 80 / 行高 28(对齐 grid 默认)
|
|
28
|
+
expect(html).toContain('<col style="width:80px">')
|
|
29
|
+
expect(html).toContain('<tr style="height:28px">')
|
|
30
|
+
// 背景色打印保色
|
|
31
|
+
expect(html).toContain('print-color-adjust: exact')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('选项:title 覆盖、landscape 方向;空名 sheet 回落「报表」', () => {
|
|
35
|
+
const html = buildFilledReportPrintHtml(createFilledSheet(), {
|
|
36
|
+
title: '月度报表',
|
|
37
|
+
orientation: 'landscape'
|
|
38
|
+
})
|
|
39
|
+
expect(html).toContain('<title>月度报表</title>')
|
|
40
|
+
expect(html).toContain('@page { size: A4 landscape;')
|
|
41
|
+
|
|
42
|
+
const fallback = buildFilledReportPrintHtml(new Sheet(''))
|
|
43
|
+
expect(fallback).toContain('<title>报表</title>')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('合并格映射 rowspan/colspan,被覆盖格不输出', () => {
|
|
47
|
+
const sheet = createFilledSheet()
|
|
48
|
+
sheet.mergeCells({ start: { row: 0, col: 0 }, end: { row: 1, col: 1 } })
|
|
49
|
+
|
|
50
|
+
const html = buildFilledReportPrintHtml(sheet)
|
|
51
|
+
expect(html).toContain('<td colspan="2" rowspan="2">客户</td>')
|
|
52
|
+
// 2×2 合并后只剩 1 个 td(锚点),覆盖格跳过
|
|
53
|
+
const tdCount = (html.match(/<td/g) ?? []).length
|
|
54
|
+
expect(tdCount).toBe(1)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('有效样式序列化为内联 CSS(fill/border/font/align/wrap)', () => {
|
|
58
|
+
const sheet = createFilledSheet()
|
|
59
|
+
sheet.setCellStyle(
|
|
60
|
+
{ start: { row: 0, col: 0 }, end: { row: 0, col: 0 } },
|
|
61
|
+
{
|
|
62
|
+
fill: { color: '#ff0000' },
|
|
63
|
+
border: { top: { style: 'thin', width: 1, color: '#000000' } },
|
|
64
|
+
font: {
|
|
65
|
+
bold: true,
|
|
66
|
+
italic: true,
|
|
67
|
+
underline: true,
|
|
68
|
+
strikethrough: true,
|
|
69
|
+
size: 14,
|
|
70
|
+
color: '#111111'
|
|
71
|
+
},
|
|
72
|
+
align: { horizontal: 'center', vertical: 'middle', wrap: true }
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
sheet.setCellStyle(
|
|
76
|
+
{ start: { row: 1, col: 1 }, end: { row: 1, col: 1 } },
|
|
77
|
+
{ align: { horizontal: 'right' } }
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const html = buildFilledReportPrintHtml(sheet)
|
|
81
|
+
expect(html).toContain('background-color:#ff0000')
|
|
82
|
+
expect(html).toContain('border-top:1px solid #000000')
|
|
83
|
+
expect(html).toContain('font-weight:bold')
|
|
84
|
+
expect(html).toContain('font-style:italic')
|
|
85
|
+
expect(html).toContain('text-decoration:underline line-through')
|
|
86
|
+
expect(html).toContain('font-size:14pt')
|
|
87
|
+
expect(html).toContain('color:#111111')
|
|
88
|
+
expect(html).toContain('text-align:center')
|
|
89
|
+
expect(html).toContain('vertical-align:middle')
|
|
90
|
+
expect(html).toContain('white-space:normal;word-break:break-all')
|
|
91
|
+
expect(html).toContain('text-align:right')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('虚线/点线边框线型映射 CSS dashed/dotted', () => {
|
|
95
|
+
const sheet = createFilledSheet()
|
|
96
|
+
sheet.setCellStyle(
|
|
97
|
+
{ start: { row: 0, col: 0 }, end: { row: 0, col: 0 } },
|
|
98
|
+
{
|
|
99
|
+
border: {
|
|
100
|
+
left: { style: 'dashed', width: 1, color: '#000000' },
|
|
101
|
+
right: { style: 'dotted', width: 2, color: '#00ff00' }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
const html = buildFilledReportPrintHtml(sheet)
|
|
106
|
+
expect(html).toContain('border-left:1px dashed #000000')
|
|
107
|
+
expect(html).toContain('border-right:2px dotted #00ff00')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('自定义列宽 / 行高写入 colgroup 与 tr', () => {
|
|
111
|
+
const sheet = createFilledSheet()
|
|
112
|
+
sheet.setColWidth(1, 160)
|
|
113
|
+
sheet.setRowHeight(1, 40)
|
|
114
|
+
|
|
115
|
+
const html = buildFilledReportPrintHtml(sheet)
|
|
116
|
+
expect(html).toContain('<col style="width:160px">')
|
|
117
|
+
expect(html).toContain('<tr style="height:40px">')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('文本与标题 HTML 转义', () => {
|
|
121
|
+
const sheet = new Sheet('报表<Q>')
|
|
122
|
+
sheet.setCells([{ addr: { row: 0, col: 0 }, data: { v: '<b>"x"</b> & y' } }])
|
|
123
|
+
|
|
124
|
+
const html = buildFilledReportPrintHtml(sheet)
|
|
125
|
+
expect(html).toContain('<title>报表<Q></title>')
|
|
126
|
+
expect(html).toContain('<td><b>"x"</b> & y</td>')
|
|
127
|
+
expect(html).not.toContain('<td><b>')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('浮动图:data URL 内嵌,锚点格左上 + 格内偏移定位,显式宽高直出', () => {
|
|
131
|
+
const sheet = createFilledSheet()
|
|
132
|
+
sheet.setColWidth(0, 100)
|
|
133
|
+
sheet.insertImage({
|
|
134
|
+
data: new Uint8Array([1, 2, 3]),
|
|
135
|
+
type: 'png',
|
|
136
|
+
anchor: { from: { row: 1, col: 1, offsetX: 10, offsetY: 5 } },
|
|
137
|
+
width: 48,
|
|
138
|
+
height: 24,
|
|
139
|
+
altText: '图章'
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const html = buildFilledReportPrintHtml(sheet)
|
|
143
|
+
// left = col0 宽 100 + 默认列宽 80(col1 前)→ 取 col1 左缘 100... colWidths=[100,80],col1 左缘=100,+offsetX=110
|
|
144
|
+
// top = row0 高 28,+offsetY=5 → 33
|
|
145
|
+
expect(html).toContain(`<img src="data:image/png;base64,AQID"`)
|
|
146
|
+
expect(html).toContain('left:110px;top:33px;width:48px;height:24px;')
|
|
147
|
+
expect(html).toContain('alt="图章"')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('浮动图:宽高缺失且有 to 时按 from→to 跨度兜底', () => {
|
|
151
|
+
const sheet = createFilledSheet()
|
|
152
|
+
sheet.insertImage({
|
|
153
|
+
data: new Uint8Array([0]),
|
|
154
|
+
type: 'jpeg',
|
|
155
|
+
anchor: { from: { row: 0, col: 0 }, to: { row: 1, col: 1 } }
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const html = buildFilledReportPrintHtml(sheet)
|
|
159
|
+
// 跨度:col0+col1 = 80+80 = 160;row0+row1 = 28+28 = 56
|
|
160
|
+
expect(html).toContain('left:0px;top:0px;width:160px;height:56px;')
|
|
161
|
+
expect(html).toContain('data:image/jpeg;base64,')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('空 sheet 输出合法空表文档', () => {
|
|
165
|
+
const html = buildFilledReportPrintHtml(new Sheet('空白'))
|
|
166
|
+
expect(html).toContain('<title>空白</title>')
|
|
167
|
+
expect(html).toContain('<table>')
|
|
168
|
+
expect(html).not.toContain('<tr')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -325,6 +325,32 @@ describe('renderReport', () => {
|
|
|
325
325
|
expect(firstAmount?.s).toBe(styleId)
|
|
326
326
|
})
|
|
327
327
|
|
|
328
|
+
it('模板格对齐样式直通 Filled Report 快照(静态格与绑定扩展格)', () => {
|
|
329
|
+
const sheet = new Sheet()
|
|
330
|
+
seedGroupDetailTemplate(sheet)
|
|
331
|
+
// 静态表头格(resolveStatic 路径)
|
|
332
|
+
sheet.setCellStyle(
|
|
333
|
+
{ start: { row: 0, col: 0 }, end: { row: 0, col: 0 } },
|
|
334
|
+
{ align: { horizontal: 'right' } }
|
|
335
|
+
)
|
|
336
|
+
// 绑定明细格(resolveCell 路径,样式随扩展实例复制)
|
|
337
|
+
sheet.setCellStyle(
|
|
338
|
+
{ start: { row: 1, col: 3 }, end: { row: 1, col: 3 } },
|
|
339
|
+
{ align: { horizontal: 'center' } }
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
const filled = renderReport(sheet.snapshot(), MOCK_DATA_RECORDS)
|
|
343
|
+
const header = filled.cells.find((c) => c.row === 0 && c.col === 0)
|
|
344
|
+
expect(filled.styles[(header!.s ?? 1) - 1]?.align?.horizontal).toBe('right')
|
|
345
|
+
|
|
346
|
+
const firstAmount = filled.cells.find((c) => c.row === 1 && c.col === 3)
|
|
347
|
+
expect(firstAmount?.s).toBeDefined()
|
|
348
|
+
expect(filled.styles[(firstAmount!.s ?? 1) - 1]?.align?.horizontal).toBe('center')
|
|
349
|
+
// 展开带后续实例同样携带对齐
|
|
350
|
+
const secondAmount = filled.cells.find((c) => c.row === 2 && c.col === 3)
|
|
351
|
+
expect(filled.styles[(secondAmount!.s ?? 1) - 1]?.align?.horizontal).toBe('center')
|
|
352
|
+
})
|
|
353
|
+
|
|
328
354
|
it('subtotal 支持 avg / count 聚合', () => {
|
|
329
355
|
const sheet = new Sheet()
|
|
330
356
|
seedGroupDetailTemplate(sheet)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { SheetSnapshot } from '@veltra/sheet-core'
|
|
2
|
+
import { cellKey } from '@veltra/sheet-core'
|
|
3
|
+
|
|
4
|
+
import { AggregateIndex, resolvePlacementContext } from './render/aggregate'
|
|
5
|
+
import { computeExpansionLayout } from './render/coordinate'
|
|
6
|
+
import { buildTemplateIndex } from './render/template-index'
|
|
7
|
+
import type { ReportTemplate } from './template'
|
|
8
|
+
import type { DatasetRecords, ParamValues, ReportDrillConfig } from './types'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 宿主模板解析契约:按下钻 target 引用取回目标模板(允许同步/异步)。
|
|
12
|
+
* 失败抛错——查看器提示可读错误并停留当前报;设计器配置对话框据此解析目标模板查询参数。
|
|
13
|
+
*/
|
|
14
|
+
export type ResolveReportTemplate = (ref: string) => ReportTemplate | Promise<ReportTemplate>
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 按下钻映射从该格对应记录取值,生成详情报 Filter Bar 参数值。
|
|
18
|
+
* 记录缺字段(值为 `undefined`)时跳过对应参数,由详情报回退默认值;空映射返回空参数。
|
|
19
|
+
*/
|
|
20
|
+
export function resolveDrillParams(
|
|
21
|
+
config: ReportDrillConfig,
|
|
22
|
+
record: Record<string, unknown>
|
|
23
|
+
): ParamValues {
|
|
24
|
+
const values: ParamValues = {}
|
|
25
|
+
for (const [field, paramId] of Object.entries(config.mapping)) {
|
|
26
|
+
const value = record[field]
|
|
27
|
+
if (value === undefined) continue
|
|
28
|
+
values[paramId] = value
|
|
29
|
+
}
|
|
30
|
+
return values
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 下钻栈层:一张报表模板 + 当时 Filter Bar 参数值(回退时据此恢复并重新取数) */
|
|
34
|
+
export interface DrillStackLayer {
|
|
35
|
+
template: ReportTemplate
|
|
36
|
+
params: ParamValues
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 下钻栈:自根(初始报表)到顶的层序列,至少一层。
|
|
41
|
+
* 只经下方纯函数做不可变更新(返回新栈),便于组件层直接挂响应式。
|
|
42
|
+
*/
|
|
43
|
+
export type DrillStack = DrillStackLayer[]
|
|
44
|
+
|
|
45
|
+
/** 以初始报表为根层创建下钻栈 */
|
|
46
|
+
export function createDrillStack(root: DrillStackLayer): DrillStack {
|
|
47
|
+
return [{ template: root.template, params: { ...root.params } }]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 压入详情报新层并使其成为当前层;`currentParams` 把当前层参数定格为离开时的值
|
|
52
|
+
* (缺省保留原值)。指向自身或成环不做检测,照常压栈,靠 pop 逐级退出。
|
|
53
|
+
*/
|
|
54
|
+
export function pushDrillLayer(
|
|
55
|
+
stack: DrillStack,
|
|
56
|
+
next: DrillStackLayer,
|
|
57
|
+
currentParams?: ParamValues
|
|
58
|
+
): DrillStack {
|
|
59
|
+
const top = stack[stack.length - 1]!
|
|
60
|
+
const layers = currentParams
|
|
61
|
+
? [...stack.slice(0, -1), { template: top.template, params: { ...currentParams } }]
|
|
62
|
+
: [...stack]
|
|
63
|
+
layers.push({ template: next.template, params: { ...next.params } })
|
|
64
|
+
return layers
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 弹出当前层返回上一层;仅剩根层时原样返回(查看器始终保留一张报表) */
|
|
68
|
+
export function popDrillLayer(stack: DrillStack): DrillStack {
|
|
69
|
+
if (stack.length <= 1) return stack
|
|
70
|
+
return stack.slice(0, -1)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 当前层(栈顶) */
|
|
74
|
+
export function currentDrillLayer(stack: DrillStack): DrillStackLayer {
|
|
75
|
+
return stack[stack.length - 1]!
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 下钻命中:物理格对应的下钻配置与该格记录 */
|
|
79
|
+
export interface DrillHit {
|
|
80
|
+
config: ReportDrillConfig
|
|
81
|
+
record: Record<string, unknown>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 构建填充报表的物理格 → 下钻命中索引(每次成功取数后重建)。
|
|
86
|
+
* 仅含配了下钻的绑定格落点(含分组/汇总格与展开后的实例格,合并跨度内每格都命中);
|
|
87
|
+
* 记录由 `resolvePlacementContext` 解析:list 明细取源数据行,分组/汇总取分组上下文。
|
|
88
|
+
*/
|
|
89
|
+
export function buildDrillHitMap(
|
|
90
|
+
template: SheetSnapshot,
|
|
91
|
+
data: DatasetRecords
|
|
92
|
+
): Map<number, DrillHit> {
|
|
93
|
+
const hits = new Map<number, DrillHit>()
|
|
94
|
+
const index = buildTemplateIndex(template)
|
|
95
|
+
const layout = computeExpansionLayout(index, data)
|
|
96
|
+
const aggregateIndex = new AggregateIndex(data)
|
|
97
|
+
|
|
98
|
+
for (const placement of layout.placements) {
|
|
99
|
+
const config = placement.binding?.drill
|
|
100
|
+
if (!config) continue
|
|
101
|
+
const record = resolvePlacementContext(placement, index, data, aggregateIndex)
|
|
102
|
+
if (!record) continue
|
|
103
|
+
|
|
104
|
+
const hit: DrillHit = { config, record }
|
|
105
|
+
for (let row = placement.physical.start.row; row <= placement.physical.end.row; row++) {
|
|
106
|
+
for (let col = placement.physical.start.col; col <= placement.physical.end.col; col++) {
|
|
107
|
+
hits.set(cellKey({ row, col }), hit)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return hits
|
|
112
|
+
}
|