@veltra/sheet 2.1.1 → 2.3.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.
Files changed (212) hide show
  1. package/dist/components/report/binding-badge.js +100 -0
  2. package/dist/components/report/designer/cell-coords.js +46 -0
  3. package/dist/components/report/designer/col-widths.js +29 -0
  4. package/dist/components/report/designer/conditional-rules/helpers.js +139 -0
  5. package/dist/components/report/designer/drop-highlight-overlay.js +76 -0
  6. package/dist/components/report/designer/float-panel.js +422 -0
  7. package/dist/components/report/designer/role.js +17 -0
  8. package/dist/components/report/designer/rule-preview.js +33 -0
  9. package/dist/components/report/designer/rule-row.js +253 -0
  10. package/dist/components/report/designer/rules-dialog.js +118 -0
  11. package/dist/components/report/designer/topology-overlay.js +122 -0
  12. package/dist/components/report/designer/topology.js +64 -0
  13. package/dist/components/report/designer/use-grid-overlay.js +75 -0
  14. package/dist/components/report/designer-hub.js +177 -0
  15. package/dist/components/report/field-panel-helpers.js +33 -0
  16. package/dist/components/report/field-panel.js +132 -0
  17. package/dist/components/report/filter-bar.js +74 -0
  18. package/dist/components/report/hub-connection-form.js +145 -0
  19. package/dist/components/report/hub-dataset-editor.js +394 -0
  20. package/dist/components/report/preview-grid-size.js +32 -0
  21. package/dist/components/report/report-designer.js +393 -0
  22. package/dist/components/report/report-viewer.js +124 -0
  23. package/dist/components/report/style.d.ts +17 -0
  24. package/dist/components/report/style.js +18 -0
  25. package/dist/components/report/style2.css +874 -0
  26. package/dist/components/report/use-report-designer.js +382 -0
  27. package/dist/components/report/use-report-viewer.js +55 -0
  28. package/dist/{vue → components/sheet}/formula-bar.js +1 -1
  29. package/dist/{vue → components/sheet}/formula-suggest-list.js +1 -1
  30. package/dist/{vue → components/sheet}/import-file.js +1 -1
  31. package/dist/{vue → components/sheet}/insert-count-menu-item.js +1 -1
  32. package/dist/{vue → components/sheet}/insert-image.js +1 -1
  33. package/dist/{vue → components/sheet}/popup-helpers.js +1 -1
  34. package/dist/{vue → components/sheet}/popups/border-popup.js +31 -3
  35. package/dist/{vue → components/sheet}/popups/border-preset-glyph.js +1 -1
  36. package/dist/{vue → components/sheet}/popups/export-popup.js +2 -2
  37. package/dist/{vue → components/sheet}/popups/fill-color-popup.js +3 -3
  38. package/dist/{vue → components/sheet}/popups/find-popup.js +1 -1
  39. package/dist/{vue → components/sheet}/popups/font-color-popup.js +3 -3
  40. package/dist/{vue → components/sheet}/popups/font-size-popup.js +3 -3
  41. package/dist/{vue → components/sheet}/popups/import.worker.d.ts +1 -1
  42. package/dist/{vue → components/sheet}/popups/import.worker.js +1 -1
  43. package/dist/{vue → components/sheet}/popups/insert-image-popup.js +1 -1
  44. package/dist/{vue → components/sheet}/sheet-context-menu.js +7 -4
  45. package/dist/{vue → components/sheet}/sheet-tabs.js +1 -1
  46. package/dist/{vue → components/sheet}/sheet-toolbar.js +1 -1
  47. package/dist/{vue → components/sheet}/sheet.js +16 -1
  48. package/dist/{vue → components/sheet}/use-find-replace.js +1 -1
  49. package/dist/{vue → components/sheet}/use-formula-ref-select.js +1 -1
  50. package/dist/{vue → components/sheet}/use-sheet-grid.js +22 -4
  51. package/dist/{vue → components/sheet}/use-sheet-state.js +6 -5
  52. package/dist/{vue → components/sheet}/use-sheet-tabs-bar.js +1 -1
  53. package/dist/{vue → components/sheet}/use-tool-groups.js +2 -2
  54. package/dist/{vue → components/sheet}/use-tool-popup.js +2 -2
  55. package/dist/{vue → components/sheet}/use-toolbar-scroll.js +1 -1
  56. package/dist/index.d.ts +17 -3
  57. package/dist/index.js +13 -3
  58. package/dist/report/binding.d.ts +83 -0
  59. package/dist/report/binding.js +332 -0
  60. package/dist/report/connector.d.ts +66 -0
  61. package/dist/report/connector.js +159 -0
  62. package/dist/report/export-xlsx.d.ts +17 -0
  63. package/dist/report/export-xlsx.js +92 -0
  64. package/dist/report/filter-bar.d.ts +10 -0
  65. package/dist/report/filter-bar.js +31 -0
  66. package/dist/report/index.d.ts +12 -0
  67. package/dist/report/params.d.ts +13 -0
  68. package/dist/report/params.js +61 -0
  69. package/dist/report/render/aggregate.js +169 -0
  70. package/dist/report/render/builder.d.ts +12 -0
  71. package/dist/report/render/builder.js +139 -0
  72. package/dist/report/render/coordinate.d.ts +25 -0
  73. package/dist/report/render/coordinate.js +733 -0
  74. package/dist/report/render/index.d.ts +14 -0
  75. package/dist/report/render/index.js +21 -0
  76. package/dist/report/render/style-resolver.js +50 -0
  77. package/dist/report/render/template-index.d.ts +24 -0
  78. package/dist/report/render/template-index.js +137 -0
  79. package/dist/report/rules/evaluate.d.ts +25 -0
  80. package/dist/report/rules/evaluate.js +82 -0
  81. package/dist/report/rules/index.d.ts +2 -0
  82. package/dist/report/template.d.ts +63 -0
  83. package/dist/report/template.js +83 -0
  84. package/dist/report/types.d.ts +85 -0
  85. package/dist/tools/apply-style.js +29 -0
  86. package/dist/tools/builtin.js +17 -17
  87. package/dist/tools/context.d.ts +39 -6
  88. package/dist/tools/context.js +38 -1
  89. package/dist/types/index.d.ts +3 -2
  90. package/dist/types/report-designer.d.ts +38 -0
  91. package/dist/types/report-viewer.d.ts +38 -0
  92. package/dist/types/sheet.d.ts +21 -1
  93. package/package.json +6 -8
  94. package/src/components/report/__test__/conditional-rules-helpers.test.ts +88 -0
  95. package/src/components/report/__test__/field-panel-helpers.test.ts +65 -0
  96. package/src/components/report/__test__/float-panel-position.test.ts +45 -0
  97. package/src/components/report/__test__/preview-grid-size.test.ts +63 -0
  98. package/src/components/report/__test__/report-designer.test.ts +464 -0
  99. package/src/components/report/__test__/report-viewer.test.ts +357 -0
  100. package/src/components/report/__test__/topology.test.ts +143 -0
  101. package/src/components/report/__test__/use-report-designer.test.ts +697 -0
  102. package/src/components/report/binding-badge.ts +92 -0
  103. package/src/components/report/designer/cell-coords.ts +94 -0
  104. package/src/components/report/designer/col-widths.ts +56 -0
  105. package/src/components/report/designer/conditional-rules/helpers.ts +189 -0
  106. package/src/components/report/designer/drop-highlight-overlay.vue +74 -0
  107. package/src/components/report/designer/float-panel.vue +431 -0
  108. package/src/components/report/designer/role.ts +32 -0
  109. package/src/components/report/designer/rule-preview.vue +40 -0
  110. package/src/components/report/designer/rule-row.vue +252 -0
  111. package/src/components/report/designer/rules-dialog.vue +110 -0
  112. package/src/components/report/designer/topology-overlay.vue +127 -0
  113. package/src/components/report/designer/topology.ts +124 -0
  114. package/src/components/report/designer/use-grid-overlay.ts +113 -0
  115. package/src/components/report/designer-hub.vue +198 -0
  116. package/src/components/report/field-panel-helpers.ts +44 -0
  117. package/src/components/report/field-panel.vue +136 -0
  118. package/src/components/report/filter-bar.vue +77 -0
  119. package/src/components/report/hub-connection-form.vue +124 -0
  120. package/src/components/report/hub-dataset-editor.vue +369 -0
  121. package/src/components/report/index.ts +2 -0
  122. package/src/components/report/preview-grid-size.ts +33 -0
  123. package/src/components/report/report-designer.vue +420 -0
  124. package/src/components/report/report-viewer.vue +136 -0
  125. package/src/components/report/style-designer.scss +587 -0
  126. package/src/components/report/style-hub.scss +292 -0
  127. package/src/components/report/style-viewer.scss +104 -0
  128. package/src/components/report/style.scss +3 -0
  129. package/src/components/report/style.ts +20 -0
  130. package/src/components/report/use-report-designer.ts +596 -0
  131. package/src/components/report/use-report-viewer.ts +88 -0
  132. package/src/{vue → components/sheet}/__test__/formula-bar.test.ts +37 -18
  133. package/src/{vue → components/sheet}/__test__/insert-image-popup.test.ts +1 -1
  134. package/src/{vue → components/sheet}/__test__/insert-image.test.ts +1 -1
  135. package/src/{vue → components/sheet}/__test__/sheet-component.test.ts +77 -3
  136. package/src/{vue → components/sheet}/__test__/sheet-context-menu.test.ts +4 -3
  137. package/src/{vue → components/sheet}/formula-bar.vue +1 -1
  138. package/src/{vue → components/sheet}/insert-image.ts +1 -1
  139. package/src/{vue → components/sheet}/popup-helpers.ts +1 -1
  140. package/src/{vue → components/sheet}/popups/border-popup.vue +32 -4
  141. package/src/{vue → components/sheet}/popups/export-popup.vue +2 -2
  142. package/src/{vue → components/sheet}/popups/fill-color-popup.vue +3 -3
  143. package/src/{vue → components/sheet}/popups/find-popup.vue +1 -1
  144. package/src/{vue → components/sheet}/popups/font-color-popup.vue +3 -3
  145. package/src/{vue → components/sheet}/popups/font-size-popup.vue +3 -3
  146. package/src/{vue → components/sheet}/popups/insert-image-popup.vue +1 -1
  147. package/src/{vue → components/sheet}/sheet-context-menu.ts +10 -4
  148. package/src/{vue → components/sheet}/sheet-toolbar.vue +2 -2
  149. package/src/{vue → components/sheet}/sheet.vue +6 -3
  150. package/src/{vue → components/sheet}/use-find-replace.ts +1 -1
  151. package/src/{vue → components/sheet}/use-sheet-grid.ts +23 -5
  152. package/src/{vue → components/sheet}/use-sheet-state.ts +11 -6
  153. package/src/{vue → components/sheet}/use-tool-groups.ts +2 -2
  154. package/src/{vue → components/sheet}/use-tool-popup.ts +2 -2
  155. package/src/index.ts +24 -188
  156. package/src/report/__test__/binding.test.ts +320 -0
  157. package/src/report/__test__/connector.test.ts +239 -0
  158. package/src/report/__test__/export-xlsx.test.ts +118 -0
  159. package/src/report/__test__/filter-bar.test.ts +58 -0
  160. package/src/report/__test__/params.test.ts +156 -0
  161. package/src/report/__test__/render.test.ts +830 -0
  162. package/src/report/__test__/rules.test.ts +131 -0
  163. package/src/report/__test__/template.test.ts +366 -0
  164. package/src/report/binding.ts +474 -0
  165. package/src/report/connector.ts +206 -0
  166. package/src/report/export-xlsx.ts +113 -0
  167. package/src/report/filter-bar.ts +36 -0
  168. package/src/report/index.ts +13 -0
  169. package/src/report/params.ts +96 -0
  170. package/src/report/render/__test__/aggregate.test.ts +16 -0
  171. package/src/report/render/__test__/coordinate.test.ts +405 -0
  172. package/src/report/render/aggregate.ts +261 -0
  173. package/src/report/render/builder.ts +211 -0
  174. package/src/report/render/coordinate.ts +1239 -0
  175. package/src/report/render/index.ts +30 -0
  176. package/src/report/render/style-resolver.ts +68 -0
  177. package/src/report/render/template-index.ts +200 -0
  178. package/src/report/rules/evaluate.ts +113 -0
  179. package/src/report/rules/index.ts +7 -0
  180. package/src/report/template.ts +145 -0
  181. package/src/report/types.ts +96 -0
  182. package/src/tools/__test__/apply-style.test.ts +129 -0
  183. package/src/tools/apply-style.ts +41 -0
  184. package/src/tools/builtin.ts +20 -20
  185. package/src/tools/context.ts +67 -4
  186. package/src/tools/index.ts +1 -0
  187. package/src/types/index.ts +2 -6
  188. package/src/types/report-designer.ts +38 -0
  189. package/src/types/report-viewer.ts +37 -0
  190. package/src/types/sheet.ts +26 -1
  191. /package/dist/{vue → components/sheet}/style.d.ts +0 -0
  192. /package/dist/{vue → components/sheet}/style.js +0 -0
  193. /package/dist/{vue → components/sheet}/style2.css +0 -0
  194. /package/dist/{vue → components/sheet}/use-formula-suggest.js +0 -0
  195. /package/src/{vue → components/sheet}/__test__/import-file.test.ts +0 -0
  196. /package/src/{vue → components/sheet}/__test__/insert-count-menu-item.test.ts +0 -0
  197. /package/src/{vue → components/sheet}/__test__/use-formula-ref-select.test.ts +0 -0
  198. /package/src/{vue → components/sheet}/__test__/use-formula-suggest.test.ts +0 -0
  199. /package/src/{vue → components/sheet}/formula-suggest-list.vue +0 -0
  200. /package/src/{vue → components/sheet}/import-file.ts +0 -0
  201. /package/src/{vue → components/sheet}/index.ts +0 -0
  202. /package/src/{vue → components/sheet}/insert-count-menu-item.vue +0 -0
  203. /package/src/{vue → components/sheet}/parsing.ts +0 -0
  204. /package/src/{vue → components/sheet}/popups/border-preset-glyph.vue +0 -0
  205. /package/src/{vue → components/sheet}/popups/import.worker.ts +0 -0
  206. /package/src/{vue → components/sheet}/sheet-tabs.vue +0 -0
  207. /package/src/{vue → components/sheet}/style.scss +0 -0
  208. /package/src/{vue → components/sheet}/style.ts +0 -0
  209. /package/src/{vue → components/sheet}/use-formula-ref-select.ts +0 -0
  210. /package/src/{vue → components/sheet}/use-formula-suggest.ts +0 -0
  211. /package/src/{vue → components/sheet}/use-sheet-tabs-bar.ts +0 -0
  212. /package/src/{vue → components/sheet}/use-toolbar-scroll.ts +0 -0
@@ -0,0 +1,113 @@
1
+ import type { Sheet } from '@veltra/sheet-core'
2
+ import { rangeToHucre, styleToHucre } from '@veltra/sheet-core/core/io/export'
3
+ import type { Cell, CellValue, ColumnDef, RowDef, WriteSheet } from 'hucre'
4
+ import { writeXlsx } from 'hucre/xlsx'
5
+
6
+ /** 列宽条目:模型列索引 → 像素宽(与 SheetSnapshot.colWidths 同构) */
7
+ export type ReportColWidthEntry = readonly [number, number]
8
+
9
+ /** 像素 → Excel points */
10
+ function pxToPt(px: number): number {
11
+ return px * 0.75
12
+ }
13
+
14
+ /** VTable 像素列宽 → Excel 字符宽度(近似换算) */
15
+ export function pxToExcelColWidth(px: number): number {
16
+ return Math.max(1, Math.round((px - 5) / 7))
17
+ }
18
+
19
+ /** 从列宽条目构建 hucre ColumnDef */
20
+ export function buildColumnDefs(colWidths: ReadonlyArray<ReportColWidthEntry>): ColumnDef[] {
21
+ if (colWidths.length === 0) return []
22
+ const maxCol = Math.max(...colWidths.map(([col]) => col))
23
+ const columns: ColumnDef[] = Array.from({ length: maxCol + 1 }, () => ({}))
24
+ for (const [col, width] of colWidths) {
25
+ columns[col] = { width: pxToExcelColWidth(width) }
26
+ }
27
+ return columns
28
+ }
29
+
30
+ /**
31
+ * 将已填充的 Sheet(Filled Report)导出为保真 XLSX(合并 / 样式 / 行高 / 列宽)。
32
+ * 条件样式颜色已在 renderReport 展开阶段打平进 StylePool(ADR-0001 决策 2)。
33
+ * 列宽取自 sheet 模型(`getColWidths`)。
34
+ */
35
+ export async function exportFilledReportXlsx(sheet: Sheet): Promise<Uint8Array> {
36
+ const widths: ReportColWidthEntry[] = [...sheet.getColWidths()]
37
+
38
+ let maxRow = -1
39
+ let maxCol = -1
40
+ for (const [addr] of sheet.store.entries()) {
41
+ if (addr.row > maxRow) maxRow = addr.row
42
+ if (addr.col > maxCol) maxCol = addr.col
43
+ }
44
+ for (const [row] of sheet.getRowHeights()) {
45
+ if (row > maxRow) maxRow = row
46
+ }
47
+ for (const [col] of widths) {
48
+ if (col > maxCol) maxCol = col
49
+ }
50
+
51
+ const rows =
52
+ maxRow < 0
53
+ ? []
54
+ : Array.from({ length: maxRow + 1 }, () =>
55
+ Array.from({ length: maxCol + 1 }, () => null as CellValue)
56
+ )
57
+ const cells = new Map<string, Partial<Cell>>()
58
+
59
+ for (const [addr, data] of sheet.store.entries()) {
60
+ const row = rows[addr.row]!
61
+ const key = `${addr.row},${addr.col}`
62
+ const cell: Partial<Cell> = {}
63
+ const style = sheet.getEffectiveStyle(addr)
64
+ if (style) cell.style = styleToHucre(style)
65
+ if (data.f != null && data.f !== '') {
66
+ cell.formula = data.f
67
+ if (data.v != null) cell.formulaResult = data.v as CellValue
68
+ row[addr.col] = (data.v as CellValue) ?? null
69
+ cells.set(key, cell)
70
+ continue
71
+ }
72
+ if (data.t === 'e') {
73
+ cell.value = data.v as string
74
+ cell.type = 'error'
75
+ row[addr.col] = null
76
+ cells.set(key, cell)
77
+ continue
78
+ }
79
+ if (data.t === 'd') {
80
+ cell.value = data.v as number
81
+ cell.type = 'number'
82
+ cell.style = { ...cell.style, numFmt: 'yyyy-mm-dd' }
83
+ row[addr.col] = null
84
+ cells.set(key, cell)
85
+ continue
86
+ }
87
+ row[addr.col] = (data.v as CellValue) ?? null
88
+ if (Object.keys(cell).length > 0) cells.set(key, cell)
89
+ }
90
+
91
+ const writeSheet: WriteSheet = {
92
+ name: sheet.name || '报表',
93
+ rows,
94
+ ...(cells.size > 0 ? { cells } : {}),
95
+ ...(sheet.merges.size > 0
96
+ ? { merges: sheet.merges.getMerges().map((range) => rangeToHucre(range)) }
97
+ : {}),
98
+ ...(widths.length > 0 ? { columns: buildColumnDefs(widths) } : {})
99
+ }
100
+
101
+ const frozen = sheet.frozen
102
+ if (frozen.rows > 0 || frozen.cols > 0) {
103
+ writeSheet.freezePane = { rows: frozen.rows, columns: frozen.cols }
104
+ }
105
+
106
+ const rowDefs = new Map<number, RowDef>()
107
+ for (const [row, height] of sheet.getRowHeights()) {
108
+ rowDefs.set(row, { height: pxToPt(height) })
109
+ }
110
+ if (rowDefs.size > 0) writeSheet.rowDefs = rowDefs
111
+
112
+ return writeXlsx({ sheets: [writeSheet], activeSheet: 0 })
113
+ }
@@ -0,0 +1,36 @@
1
+ import type { ParamValues } from './types'
2
+
3
+ // Filter Bar 控件值规范化(自 playground filter-bar-helpers 平移,行为不变)
4
+
5
+ function toParamString(value: unknown): string {
6
+ if (value == null || value === '') return ''
7
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
8
+ return String(value)
9
+ }
10
+ return ''
11
+ }
12
+
13
+ /** 将运行时 date-range 参数值规范为 [from, to] 字符串元组 */
14
+ export function parseDateRangeValue(raw: unknown): [string, string] {
15
+ if (Array.isArray(raw)) {
16
+ return [toParamString(raw[0]), toParamString(raw[1])]
17
+ }
18
+ if (raw && typeof raw === 'object') {
19
+ const obj = raw as Record<string, unknown>
20
+ return [toParamString(obj.from ?? obj.start), toParamString(obj.to ?? obj.end)]
21
+ }
22
+ return ['', '']
23
+ }
24
+
25
+ /** 将运行时 number 参数值规范为 UNumberInput 可接受的 number | undefined */
26
+ export function resolveNumberParamValue(raw: unknown): number | undefined {
27
+ if (raw === '' || raw == null) return undefined
28
+ if (typeof raw === 'number' && Number.isFinite(raw)) return raw
29
+ const parsed = Number(raw)
30
+ return Number.isFinite(parsed) ? parsed : undefined
31
+ }
32
+
33
+ /** 合并单参数变更,保持其余参数值不变 */
34
+ export function patchParamValues(values: ParamValues, id: string, value: unknown): ParamValues {
35
+ return { ...values, [id]: value }
36
+ }
@@ -0,0 +1,13 @@
1
+ // 报表纯 TS 内核(ADR-0003 决策 1):渲染引擎 / 绑定 / 条件规则 / 查询参数 / 数据连接器 /
2
+ // Report Template(自包含模板与取数)/ Filter Bar 值规范化。
3
+ // 框架无关、无 DOM 依赖;组件壳(UReportDesigner / UReportViewer)落 components/report/。
4
+
5
+ export * from './types'
6
+ export * from './binding'
7
+ export * from './rules'
8
+ export * from './params'
9
+ export * from './render'
10
+ export * from './connector'
11
+ export * from './template'
12
+ export * from './filter-bar'
13
+ export * from './export-xlsx'
@@ -0,0 +1,96 @@
1
+ import type { QueryParamDef, QueryParamType } from './types'
2
+
3
+ /** 从 SQL 提取 `${param}` 占位符 id(ADR-0002 决策 2:前端纯函数) */
4
+ export function extractParamIds(sql: string): string[] {
5
+ const ids: string[] = []
6
+ const re = /\$\{([^}]+)\}/g
7
+ let match: RegExpExecArray | null
8
+ while ((match = re.exec(sql)) !== null) {
9
+ const id = match[1]!.trim()
10
+ if (id && !ids.includes(id)) ids.push(id)
11
+ }
12
+ return ids
13
+ }
14
+
15
+ function escapeRegExp(value: string): string {
16
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
17
+ }
18
+
19
+ /** 从 SQL 上下文推断参数控件类型(BETWEEN/LIKE/数值比较) */
20
+ function inferParamTypeFromSql(sql: string, paramId: string): QueryParamType {
21
+ const betweenSingle = new RegExp(
22
+ `\\bBETWEEN\\s+\\$\\{${escapeRegExp(paramId)}\\}(?!\\s+AND)`,
23
+ 'i'
24
+ )
25
+ if (betweenSingle.test(sql)) return 'date-range'
26
+
27
+ const betweenPair = new RegExp(
28
+ `\\bBETWEEN\\s+\\$\\{${escapeRegExp(paramId)}\\}\\s+AND\\s+\\$\\{([^}]+)\\}`,
29
+ 'i'
30
+ )
31
+ const pairMatch = betweenPair.exec(sql)
32
+ if (pairMatch && pairMatch[1]!.trim() !== paramId) return 'date'
33
+
34
+ const like = new RegExp(`\\bLIKE\\s+\\$\\{${escapeRegExp(paramId)}\\}`, 'i')
35
+ if (like.test(sql)) return 'text'
36
+
37
+ const numericCompare = new RegExp(
38
+ `[<>=]+\\s+\\$\\{${escapeRegExp(paramId)}\\}|\\$\\{${escapeRegExp(paramId)}\\}\\s*[<>=]+`,
39
+ 'i'
40
+ )
41
+ if (numericCompare.test(sql)) return 'number'
42
+
43
+ return 'text'
44
+ }
45
+
46
+ function defaultValueForType(type: QueryParamType): unknown {
47
+ switch (type) {
48
+ case 'number':
49
+ return 0
50
+ case 'date-range':
51
+ return ['', '']
52
+ case 'select':
53
+ return ''
54
+ default:
55
+ return ''
56
+ }
57
+ }
58
+
59
+ /**
60
+ * 从 SQL 构建查询参数定义:提取 `${param}` 占位符 + 上下文类型推断,
61
+ * 再叠加数据集编辑器的元数据覆盖(label / 类型 / 默认值 / 选项)。
62
+ */
63
+ export function buildParamDefs(
64
+ sql: string,
65
+ overrides?: Record<string, Partial<Omit<QueryParamDef, 'id'>>>
66
+ ): QueryParamDef[] {
67
+ const ids = extractParamIds(sql)
68
+ return ids.map((id) => {
69
+ const inferred = inferParamTypeFromSql(sql, id)
70
+ const override = overrides?.[id]
71
+ return {
72
+ id,
73
+ label: override?.label ?? id,
74
+ type: override?.type ?? inferred,
75
+ defaultValue: override?.defaultValue ?? defaultValueForType(override?.type ?? inferred),
76
+ options: override?.options
77
+ }
78
+ })
79
+ }
80
+
81
+ /** 按绑定的数据集 id 合并查询参数(同名参数先见为准) */
82
+ export function resolveBoundDatasetParams(
83
+ queryParams: QueryParamDef[],
84
+ boundDatasetIds: readonly string[],
85
+ getDatasetParams: (datasetId: string) => QueryParamDef[]
86
+ ): QueryParamDef[] {
87
+ const map = new Map<string, QueryParamDef>()
88
+ for (const datasetId of boundDatasetIds) {
89
+ const params = getDatasetParams(datasetId)
90
+ for (const param of params) {
91
+ if (!map.has(param.id)) map.set(param.id, param)
92
+ }
93
+ }
94
+ if (map.size > 0) return Array.from(map.values())
95
+ return queryParams
96
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { aggregateField } from '../aggregate'
4
+
5
+ describe('aggregateField', () => {
6
+ it('avg 空集返回 undefined,sum 空集仍为 0', () => {
7
+ expect(aggregateField([], 'amount', 'avg')).toBeUndefined()
8
+ expect(aggregateField([], 'amount', 'sum')).toBe(0)
9
+ })
10
+
11
+ it('max / min 对数值集求值', () => {
12
+ const rows = [{ amount: 10 }, { amount: 30 }, { amount: 20 }]
13
+ expect(aggregateField(rows, 'amount', 'max')).toBe(30)
14
+ expect(aggregateField(rows, 'amount', 'min')).toBe(10)
15
+ })
16
+ })
@@ -0,0 +1,405 @@
1
+ import { Sheet } from '@veltra/sheet-core'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { REPORT_META_NAMESPACE, createReportBinding } from '../../binding'
5
+ import type { DatasetRecords, ReportBinding } from '../../types'
6
+ import {
7
+ computeExpansionLayout,
8
+ enumerateExpansionInstances,
9
+ physicalColSpan,
10
+ physicalRowSpan,
11
+ placementsAt
12
+ } from '../coordinate'
13
+ import { TemplateStructureError, buildTemplateIndex } from '../template-index'
14
+
15
+ // ---- fixtures ----
16
+
17
+ const ORDERS: DatasetRecords = {
18
+ orders: [
19
+ { customer: '甲', region: '华东', orderNo: 'O-1', amount: 100 },
20
+ { customer: '甲', region: '华东', orderNo: 'O-2', amount: 200 },
21
+ { customer: '乙', region: '华南', orderNo: 'O-3', amount: 300 },
22
+ { customer: '丙', region: '华北', orderNo: 'O-4', amount: 400 }
23
+ ]
24
+ }
25
+
26
+ const MATRIX: DatasetRecords = {
27
+ sales: [
28
+ { region: '华东', category: 'A', amount: 10 },
29
+ { region: '华东', category: 'B', amount: 20 },
30
+ { region: '华南', category: 'A', amount: 30 },
31
+ { region: '华南', category: 'B', amount: 40 }
32
+ ]
33
+ }
34
+
35
+ const MULTI_DS: DatasetRecords = { orders: ORDERS.orders!, customers: [{ id: 'C1' }, { id: 'C2' }] }
36
+
37
+ function bind(sheet: Sheet, addr: { row: number; col: number }, binding: ReportBinding): void {
38
+ sheet.setCellMeta(addr, REPORT_META_NAMESPACE, binding)
39
+ }
40
+
41
+ function snapshot(sheet: Sheet) {
42
+ return sheet.snapshot()
43
+ }
44
+
45
+ function layoutOf(sheet: Sheet, data: DatasetRecords = ORDERS) {
46
+ const index = buildTemplateIndex(snapshot(sheet))
47
+ return computeExpansionLayout(index, data)
48
+ }
49
+
50
+ function placementRange(
51
+ layout: ReturnType<typeof layoutOf>,
52
+ row: number,
53
+ col: number,
54
+ listIndex = 0
55
+ ) {
56
+ const items = placementsAt(layout, { row, col })
57
+ const hit = items[listIndex] ?? items[0]
58
+ expect(hit, `逻辑格 R${row}C${col} 无映射`).toBeDefined()
59
+ return hit!.physical
60
+ }
61
+
62
+ // ---- template-index ----
63
+
64
+ describe('buildTemplateIndex', () => {
65
+ it('父格不存在时抛可读错误', () => {
66
+ const sheet = new Sheet()
67
+ const binding = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
68
+ binding.rowParent = { row: 0, col: 9 }
69
+ bind(sheet, { row: 1, col: 0 }, binding)
70
+
71
+ expect(() => buildTemplateIndex(snapshot(sheet))).toThrow(TemplateStructureError)
72
+ expect(() => buildTemplateIndex(snapshot(sheet))).toThrow(/J1/)
73
+ })
74
+
75
+ it('父链成环时抛可读错误', () => {
76
+ const sheet = new Sheet()
77
+ const a = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
78
+ const b = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'region')
79
+ a.rowParent = { row: 2, col: 0 }
80
+ b.rowParent = { row: 1, col: 0 }
81
+ bind(sheet, { row: 1, col: 0 }, a)
82
+ bind(sheet, { row: 2, col: 0 }, b)
83
+
84
+ expect(() => buildTemplateIndex(snapshot(sheet))).toThrow(/父链存在环/)
85
+ })
86
+ })
87
+
88
+ // ---- enumerateExpansionInstances ----
89
+
90
+ describe('enumerateExpansionInstances', () => {
91
+ const groupBinding: ReportBinding = {
92
+ dataset: 'orders',
93
+ field: 'customer',
94
+ expand: 'down',
95
+ aggregate: 'group'
96
+ }
97
+
98
+ it('group 取去重值数', () => {
99
+ const instances = enumerateExpansionInstances(groupBinding, ORDERS, {})
100
+ expect(instances).toHaveLength(3)
101
+ expect(instances.map((i) => i.value)).toEqual(['甲', '乙', '丙'])
102
+ })
103
+
104
+ it('list 取记录数', () => {
105
+ const listBinding: ReportBinding = {
106
+ dataset: 'orders',
107
+ field: 'orderNo',
108
+ expand: 'down',
109
+ aggregate: 'list'
110
+ }
111
+ expect(enumerateExpansionInstances(listBinding, ORDERS, {})).toHaveLength(4)
112
+ })
113
+
114
+ it('expand none 恒为 1 个实例', () => {
115
+ const noneBinding: ReportBinding = {
116
+ dataset: 'orders',
117
+ field: 'amount',
118
+ expand: 'none',
119
+ aggregate: 'sum'
120
+ }
121
+ expect(enumerateExpansionInstances(noneBinding, ORDERS, {})).toHaveLength(1)
122
+ })
123
+
124
+ it('空数据集 group 产出 0 实例', () => {
125
+ expect(enumerateExpansionInstances(groupBinding, { orders: [] }, {})).toHaveLength(0)
126
+ })
127
+ })
128
+
129
+ // ---- coordinate geometry ----
130
+
131
+ describe('computeExpansionLayout', () => {
132
+ it('单级纵向:分组头 merge 跨度 = 明细行数,静态表头不被推移', () => {
133
+ const sheet = new Sheet()
134
+ sheet.setCells([
135
+ { addr: { row: 0, col: 0 }, data: { v: '客户' } },
136
+ { addr: { row: 0, col: 1 }, data: { v: '订单号' } }
137
+ ])
138
+ const group = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
139
+ group.aggregate = 'group'
140
+ group.expand = 'down'
141
+ bind(sheet, { row: 1, col: 0 }, group)
142
+
143
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
144
+ detail.aggregate = 'list'
145
+ detail.expand = 'down'
146
+ detail.rowParent = { row: 1, col: 0 }
147
+ bind(sheet, { row: 1, col: 1 }, detail)
148
+
149
+ const layout = layoutOf(sheet)
150
+ // 表头 1 + 甲2 + 乙1 + 丙1 = 5 行
151
+ expect(layout.rowCount).toBe(5)
152
+
153
+ const header = placementRange(layout, 0, 0)
154
+ expect(header.start).toEqual({ row: 0, col: 0 })
155
+
156
+ const groupA = placementsAt(layout, { row: 1, col: 0 }).find((p) => p.rowPath[0] === 0)!
157
+ expect(physicalRowSpan(groupA)).toBe(2)
158
+ expect(groupA.physical.start.row).toBe(1)
159
+
160
+ const groupB = placementsAt(layout, { row: 1, col: 0 }).find((p) => p.rowPath[0] === 1)!
161
+ expect(physicalRowSpan(groupB)).toBe(1)
162
+ expect(groupB.physical.start.row).toBe(3)
163
+ })
164
+
165
+ it('多级纵向嵌套:子分组在父实例内展开', () => {
166
+ const sheet = new Sheet()
167
+ const data: DatasetRecords = {
168
+ orders: [
169
+ { region: '华东', customer: '甲', orderNo: '1' },
170
+ { region: '华东', customer: '甲', orderNo: '2' },
171
+ { region: '华东', customer: '乙', orderNo: '3' },
172
+ { region: '华南', customer: '丙', orderNo: '4' }
173
+ ]
174
+ }
175
+
176
+ const region = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'region')
177
+ region.aggregate = 'group'
178
+ region.expand = 'down'
179
+ bind(sheet, { row: 1, col: 0 }, region)
180
+
181
+ const customer = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
182
+ customer.aggregate = 'group'
183
+ customer.expand = 'down'
184
+ customer.rowParent = { row: 1, col: 0 }
185
+ bind(sheet, { row: 2, col: 0 }, customer)
186
+
187
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
188
+ detail.aggregate = 'list'
189
+ detail.expand = 'down'
190
+ detail.rowParent = { row: 2, col: 0 }
191
+ bind(sheet, { row: 2, col: 1 }, detail)
192
+
193
+ const layout = layoutOf(sheet, data)
194
+ // 华东(甲2+乙1) + 华南(丙1) = 3 组块 × 各自明细 = 4 明细行
195
+ expect(layout.rowCount).toBe(4)
196
+
197
+ const eastCustomer = placementsAt(layout, { row: 2, col: 0 }).find(
198
+ (p) => p.rowPath[0] === 0 && p.rowPath[1] === 0
199
+ )!
200
+ expect(physicalRowSpan(eastCustomer)).toBe(2)
201
+ })
202
+
203
+ it('单级横向:列分组向右延展', () => {
204
+ const sheet = new Sheet()
205
+ sheet.setCells([{ addr: { row: 0, col: 0 }, data: { v: '品类' } }])
206
+
207
+ const category = createReportBinding({ id: 'sales', label: 's', fields: [] }, 'category')
208
+ category.aggregate = 'group'
209
+ category.expand = 'right'
210
+ bind(sheet, { row: 0, col: 1 }, category)
211
+
212
+ const layout = layoutOf(sheet, MATRIX)
213
+ expect(layout.colCount).toBe(3) // 标题 + A/B 两列
214
+ const headers = placementsAt(layout, { row: 0, col: 1 })
215
+ expect(headers).toHaveLength(2)
216
+ expect(headers[0]!.physical.start.col).toBe(1)
217
+ expect(headers[1]!.physical.start.col).toBe(2)
218
+ expect(physicalColSpan(headers[0]!)).toBe(1)
219
+ })
220
+
221
+ it('多级列头:colParent 链逐级向右嵌套', () => {
222
+ const sheet = new Sheet()
223
+ const data: DatasetRecords = {
224
+ sales: [
225
+ { year: '2024', quarter: 'Q1', amount: 1 },
226
+ { year: '2024', quarter: 'Q2', amount: 2 },
227
+ { year: '2025', quarter: 'Q1', amount: 3 }
228
+ ]
229
+ }
230
+
231
+ const year = createReportBinding({ id: 'sales', label: 's', fields: [] }, 'year')
232
+ year.aggregate = 'group'
233
+ year.expand = 'right'
234
+ bind(sheet, { row: 0, col: 0 }, year)
235
+
236
+ const quarter = createReportBinding({ id: 'sales', label: 's', fields: [] }, 'quarter')
237
+ quarter.aggregate = 'group'
238
+ quarter.expand = 'right'
239
+ quarter.colParent = { row: 0, col: 0 }
240
+ bind(sheet, { row: 1, col: 0 }, quarter)
241
+
242
+ const layout = layoutOf(sheet, data)
243
+ const yearPlacements = placementsAt(layout, { row: 0, col: 0 })
244
+ expect(yearPlacements).toHaveLength(2)
245
+ expect(physicalColSpan(yearPlacements[0]!)).toBe(2)
246
+ expect(physicalColSpan(yearPlacements[1]!)).toBe(1)
247
+
248
+ const quarterPlacements = placementsAt(layout, { row: 1, col: 0 })
249
+ expect(quarterPlacements).toHaveLength(3)
250
+ })
251
+
252
+ it('行列同时展开:交叉格按双父实例落位', () => {
253
+ const sheet = new Sheet()
254
+ sheet.setCells([
255
+ { addr: { row: 0, col: 0 }, data: { v: '标题' } },
256
+ { addr: { row: 2, col: 0 }, data: { v: '合计' } }
257
+ ])
258
+
259
+ const category = createReportBinding({ id: 'sales', label: 's', fields: [] }, 'category')
260
+ category.aggregate = 'group'
261
+ category.expand = 'right'
262
+ bind(sheet, { row: 0, col: 1 }, category)
263
+
264
+ const region = createReportBinding({ id: 'sales', label: 's', fields: [] }, 'region')
265
+ region.aggregate = 'group'
266
+ region.expand = 'down'
267
+ bind(sheet, { row: 1, col: 0 }, region)
268
+
269
+ const cross = createReportBinding({ id: 'sales', label: 's', fields: [] }, 'amount')
270
+ cross.aggregate = 'sum'
271
+ cross.expand = 'none'
272
+ cross.rowParent = { row: 1, col: 0 }
273
+ cross.colParent = { row: 0, col: 1 }
274
+ bind(sheet, { row: 1, col: 1 }, cross)
275
+
276
+ const layout = layoutOf(sheet, MATRIX)
277
+ expect(layout.rowCount).toBe(4) // 表头 + 2 地区 + 合计
278
+ expect(layout.colCount).toBe(3) // 角 + 2 品类
279
+
280
+ const crossCells = placementsAt(layout, { row: 1, col: 1 })
281
+ expect(crossCells).toHaveLength(4)
282
+ expect(crossCells.map((p) => p.physical.start)).toEqual(
283
+ expect.arrayContaining([
284
+ { row: 1, col: 1 },
285
+ { row: 1, col: 2 },
286
+ { row: 2, col: 1 },
287
+ { row: 2, col: 2 }
288
+ ])
289
+ )
290
+ })
291
+
292
+ it('mergeSpan: false 时扩展格不合并,逐格占位', () => {
293
+ const sheet = new Sheet()
294
+ const group = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
295
+ group.aggregate = 'group'
296
+ group.expand = 'down'
297
+ group.mergeSpan = false
298
+ bind(sheet, { row: 1, col: 0 }, group)
299
+
300
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
301
+ detail.aggregate = 'list'
302
+ detail.expand = 'down'
303
+ detail.rowParent = { row: 1, col: 0 }
304
+ bind(sheet, { row: 1, col: 1 }, detail)
305
+
306
+ const layout = layoutOf(sheet)
307
+ const groupCells = placementsAt(layout, { row: 1, col: 0 })
308
+ expect(groupCells.every((p) => !p.mergeSpan)).toBe(true)
309
+ expect(groupCells.every((p) => physicalRowSpan(p) === 1)).toBe(true)
310
+ expect(groupCells).toHaveLength(4)
311
+ })
312
+
313
+ it('静态格在扩展带内随明细重复占位', () => {
314
+ const sheet = new Sheet()
315
+ sheet.setCells([{ addr: { row: 1, col: 2 }, data: { v: '备注' } }])
316
+
317
+ const group = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
318
+ group.aggregate = 'group'
319
+ group.expand = 'down'
320
+ bind(sheet, { row: 1, col: 0 }, group)
321
+
322
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
323
+ detail.aggregate = 'list'
324
+ detail.expand = 'down'
325
+ detail.rowParent = { row: 1, col: 0 }
326
+ bind(sheet, { row: 1, col: 1 }, detail)
327
+
328
+ const layout = layoutOf(sheet)
329
+ const notes = placementsAt(layout, { row: 1, col: 2 })
330
+ expect(notes).toHaveLength(4)
331
+ expect(notes.map((p) => p.physical.start.row)).toEqual([0, 1, 2, 3])
332
+ })
333
+
334
+ it('空展开:扩展格 0 跨度,子树不占位,静态表头仍映射', () => {
335
+ const sheet = new Sheet()
336
+ sheet.setCells([{ addr: { row: 0, col: 0 }, data: { v: '客户' } }])
337
+
338
+ const group = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
339
+ group.aggregate = 'group'
340
+ group.expand = 'down'
341
+ bind(sheet, { row: 1, col: 0 }, group)
342
+
343
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
344
+ detail.aggregate = 'list'
345
+ detail.expand = 'down'
346
+ detail.rowParent = { row: 1, col: 0 }
347
+ bind(sheet, { row: 1, col: 1 }, detail)
348
+
349
+ const layout = layoutOf(sheet, { orders: [] })
350
+ expect(layout.rowCount).toBe(1)
351
+ expect(placementsAt(layout, { row: 0, col: 0 })).toHaveLength(1)
352
+ expect(placementsAt(layout, { row: 1, col: 0 })).toHaveLength(0)
353
+ })
354
+
355
+ it('跨数据集:父格过滤不同数据集时不串联', () => {
356
+ const sheet = new Sheet()
357
+ sheet.setCells([{ addr: { row: 0, col: 5 }, data: { v: '客户ID' } }])
358
+
359
+ const group = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
360
+ group.aggregate = 'group'
361
+ group.expand = 'down'
362
+ bind(sheet, { row: 1, col: 0 }, group)
363
+
364
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
365
+ detail.aggregate = 'list'
366
+ detail.expand = 'down'
367
+ detail.rowParent = { row: 1, col: 0 }
368
+ bind(sheet, { row: 1, col: 1 }, detail)
369
+
370
+ const customers = createReportBinding({ id: 'customers', label: 'c', fields: [] }, 'id')
371
+ customers.aggregate = 'list'
372
+ customers.expand = 'down'
373
+ bind(sheet, { row: 4, col: 5 }, customers)
374
+
375
+ const layout = layoutOf(sheet, MULTI_DS)
376
+ // 订单 4 明细行 + 表头 1 + 客户列表 2 行
377
+ expect(layout.rowCount).toBe(4 + 1 + 2)
378
+ const customerIds = placementsAt(layout, { row: 4, col: 5 })
379
+ expect(customerIds).toHaveLength(2)
380
+ expect(customerIds[0]!.physical.start.row).toBe(5)
381
+ })
382
+
383
+ it('模板合并区域映射到物理表头行', () => {
384
+ const sheet = new Sheet()
385
+ sheet.setCells([
386
+ { addr: { row: 0, col: 0 }, data: { v: '客户' } },
387
+ { addr: { row: 0, col: 1 }, data: { v: '订单号' } }
388
+ ])
389
+ sheet.mergeCells({ start: { row: 0, col: 0 }, end: { row: 0, col: 1 } })
390
+
391
+ const group = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'customer')
392
+ group.aggregate = 'group'
393
+ group.expand = 'down'
394
+ bind(sheet, { row: 1, col: 0 }, group)
395
+
396
+ const detail = createReportBinding({ id: 'orders', label: 'o', fields: [] }, 'orderNo')
397
+ detail.aggregate = 'list'
398
+ detail.expand = 'down'
399
+ detail.rowParent = { row: 1, col: 0 }
400
+ bind(sheet, { row: 1, col: 1 }, detail)
401
+
402
+ const layout = layoutOf(sheet)
403
+ expect(layout.mappedMerges).toEqual([{ start: { row: 0, col: 0 }, end: { row: 0, col: 1 } }])
404
+ })
405
+ })