@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,474 @@
1
+ import type { CellAddress } from '@veltra/sheet-core'
2
+ import { formatAddress, parseAddress } from '@veltra/sheet-core'
3
+
4
+ import type {
5
+ DatasetCatalogItem,
6
+ ReportAggregate,
7
+ ReportBinding,
8
+ ReportExpand,
9
+ ReportPreset
10
+ } from './types'
11
+
12
+ /** 字段 label 解析用的 catalog(由宿主注入) */
13
+ let bindingCatalog: DatasetCatalogItem[] = []
14
+
15
+ export function setBindingCatalog(catalog: DatasetCatalogItem[]): void {
16
+ bindingCatalog = catalog
17
+ }
18
+
19
+ export const REPORT_META_NAMESPACE = 'report'
20
+
21
+ const AGGREGATE_PLACEHOLDER_TAG: Record<ReportAggregate, string> = {
22
+ list: '明细',
23
+ group: '分组',
24
+ sum: '求和',
25
+ avg: '平均',
26
+ count: '计数',
27
+ max: '最大',
28
+ min: '最小'
29
+ }
30
+
31
+ /** 预设 → 绑定字段补丁(父格除 grandTotal 外均保留推断值) */
32
+ export function presetBindingPatch(
33
+ preset: ReportPreset,
34
+ options?: { transpose?: boolean }
35
+ ): Partial<ReportBinding> {
36
+ const expand: ReportExpand = options?.transpose ? 'right' : 'down'
37
+ switch (preset) {
38
+ case 'groupHeader':
39
+ return { preset, expand, aggregate: 'group' }
40
+ case 'detail':
41
+ return { preset, expand, aggregate: 'list' }
42
+ case 'subtotal':
43
+ return { preset, expand: 'none', aggregate: 'sum' }
44
+ case 'grandTotal':
45
+ return {
46
+ preset,
47
+ expand: 'none',
48
+ aggregate: 'sum',
49
+ rowParent: undefined,
50
+ colParent: undefined
51
+ }
52
+ case 'cross':
53
+ return { preset, expand: 'none', aggregate: 'sum' }
54
+ }
55
+ }
56
+
57
+ /** 将预设写入绑定(grandTotal 清空双父格;其余保留既有父格) */
58
+ export function applyReportPreset(
59
+ binding: ReportBinding,
60
+ preset: ReportPreset,
61
+ options?: { transpose?: boolean }
62
+ ): ReportBinding {
63
+ const patch = presetBindingPatch(preset, options)
64
+ const next: ReportBinding = { ...binding, ...patch }
65
+ if (preset === 'grandTotal') {
66
+ delete next.rowParent
67
+ delete next.colParent
68
+ }
69
+ return next
70
+ }
71
+
72
+ export function isVerticalExpandBinding(binding: ReportBinding): boolean {
73
+ return (
74
+ binding.expand === 'down' && (binding.aggregate === 'group' || binding.aggregate === 'list')
75
+ )
76
+ }
77
+
78
+ export function isHorizontalExpandBinding(binding: ReportBinding): boolean {
79
+ return (
80
+ binding.expand === 'right' && (binding.aggregate === 'group' || binding.aggregate === 'list')
81
+ )
82
+ }
83
+
84
+ export function isRowGroupBinding(binding: ReportBinding): boolean {
85
+ return binding.expand === 'down' && binding.aggregate === 'group'
86
+ }
87
+
88
+ export function isColGroupBinding(binding: ReportBinding): boolean {
89
+ return binding.expand === 'right' && binding.aggregate === 'group'
90
+ }
91
+
92
+ export function isReductionAggregate(aggregate: ReportAggregate): boolean {
93
+ return (
94
+ aggregate === 'sum' ||
95
+ aggregate === 'avg' ||
96
+ aggregate === 'count' ||
97
+ aggregate === 'max' ||
98
+ aggregate === 'min'
99
+ )
100
+ }
101
+
102
+ /** 小计 / 汇总 / 交叉格的父格候选只列分组头 */
103
+ export function prefersGroupParent(binding: ReportBinding): boolean {
104
+ if (
105
+ binding.preset === 'subtotal' ||
106
+ binding.preset === 'grandTotal' ||
107
+ binding.preset === 'cross'
108
+ ) {
109
+ return true
110
+ }
111
+ return binding.expand === 'none' && isReductionAggregate(binding.aggregate)
112
+ }
113
+
114
+ /** 当前 expand / aggregate 是否仍属于已写入的预设族(同族内改聚合不掉到「自定义」) */
115
+ export function presetStillApplies(binding: ReportBinding): boolean {
116
+ const preset = binding.preset
117
+ if (!preset) return false
118
+ if (preset === 'groupHeader') {
119
+ return (
120
+ binding.aggregate === 'group' && (binding.expand === 'down' || binding.expand === 'right')
121
+ )
122
+ }
123
+ if (preset === 'detail') {
124
+ return binding.aggregate === 'list' && (binding.expand === 'down' || binding.expand === 'right')
125
+ }
126
+ return binding.expand === 'none' && isReductionAggregate(binding.aggregate)
127
+ }
128
+
129
+ const DEFAULT_SCAN_COLS = 64
130
+ const DEFAULT_SCAN_ROWS = 64
131
+
132
+ type GetBindingAt = (addr: CellAddress) => ReportBinding | undefined
133
+
134
+ export interface InferParentOptions {
135
+ /** 小计落格:必须优先分组头,禁止把同列/同行明细当成父格 */
136
+ preferGroup?: boolean
137
+ }
138
+
139
+ /** 指定模板行是否含纵向扩展绑定(展开带行) */
140
+ export function isExpansionBandRow(
141
+ row: number,
142
+ getBindingAt: GetBindingAt,
143
+ scanCols = DEFAULT_SCAN_COLS
144
+ ): boolean {
145
+ for (let col = 0; col < scanCols; col++) {
146
+ const binding = getBindingAt({ row, col })
147
+ if (binding && isVerticalExpandBinding(binding)) return true
148
+ }
149
+ return false
150
+ }
151
+
152
+ /** 指定模板列是否含横向扩展绑定(展开带列) */
153
+ export function isExpansionBandCol(
154
+ col: number,
155
+ getBindingAt: GetBindingAt,
156
+ scanRows = DEFAULT_SCAN_ROWS
157
+ ): boolean {
158
+ for (let row = 0; row < scanRows; row++) {
159
+ const binding = getBindingAt({ row, col })
160
+ if (binding && isHorizontalExpandBinding(binding)) return true
161
+ }
162
+ return false
163
+ }
164
+
165
+ /** 落格时推断预设:默认明细;数值字段且位于展开带正下方或正右相邻 → 小计 */
166
+ export function inferDropPreset(
167
+ addr: CellAddress,
168
+ fieldType: DatasetCatalogItem['fields'][number]['type'],
169
+ getBindingAt: GetBindingAt
170
+ ): ReportPreset {
171
+ if (fieldType !== 'number') return 'detail'
172
+ if (addr.row > 0 && isExpansionBandRow(addr.row - 1, getBindingAt)) return 'subtotal'
173
+ if (addr.col > 0 && isExpansionBandCol(addr.col - 1, getBindingAt)) return 'subtotal'
174
+ return 'detail'
175
+ }
176
+
177
+ export interface ListParentCandidateOptions {
178
+ preferGroup?: boolean
179
+ }
180
+
181
+ /** 同数据集纵向扩展绑定格(行方向父格下拉候选) */
182
+ export function listRowParentCandidates(
183
+ binding: ReportBinding,
184
+ entries: ReadonlyArray<{ addr: CellAddress; binding: ReportBinding }>,
185
+ options?: ListParentCandidateOptions
186
+ ): CellAddress[] {
187
+ const preferGroup = options?.preferGroup === true
188
+ const candidates: CellAddress[] = []
189
+ for (const entry of entries) {
190
+ if (entry.binding.dataset !== binding.dataset) continue
191
+ if (preferGroup) {
192
+ if (!isRowGroupBinding(entry.binding)) continue
193
+ } else if (!isVerticalExpandBinding(entry.binding)) {
194
+ continue
195
+ }
196
+ candidates.push(entry.addr)
197
+ }
198
+ return candidates
199
+ }
200
+
201
+ /** 同数据集横向扩展绑定格(列方向父格下拉候选) */
202
+ export function listColParentCandidates(
203
+ binding: ReportBinding,
204
+ entries: ReadonlyArray<{ addr: CellAddress; binding: ReportBinding }>,
205
+ options?: ListParentCandidateOptions
206
+ ): CellAddress[] {
207
+ const preferGroup = options?.preferGroup === true
208
+ const candidates: CellAddress[] = []
209
+ for (const entry of entries) {
210
+ if (entry.binding.dataset !== binding.dataset) continue
211
+ if (preferGroup) {
212
+ if (!isColGroupBinding(entry.binding)) continue
213
+ } else if (!isHorizontalExpandBinding(entry.binding)) {
214
+ continue
215
+ }
216
+ candidates.push(entry.addr)
217
+ }
218
+ return candidates
219
+ }
220
+
221
+ interface ScanHit {
222
+ addr: CellAddress
223
+ group: boolean
224
+ distance: number
225
+ }
226
+
227
+ function betterHit(current: ScanHit | null, next: ScanHit): boolean {
228
+ if (!current) return true
229
+ if (next.distance !== current.distance) return next.distance < current.distance
230
+ return next.addr.col + next.addr.row < current.addr.col + current.addr.row
231
+ }
232
+
233
+ function pickExpandHit(hits: ScanHit[], preferGroup: boolean): CellAddress | null {
234
+ let group: ScanHit | null = null
235
+ let expand: ScanHit | null = null
236
+ for (const hit of hits) {
237
+ if (hit.group && betterHit(group, hit)) group = hit
238
+ if (betterHit(expand, hit)) expand = hit
239
+ }
240
+ if (preferGroup) return group?.addr ?? null
241
+ return group?.addr ?? expand?.addr ?? null
242
+ }
243
+
244
+ function scanVerticalOnRow(
245
+ row: number,
246
+ originCol: number,
247
+ colStart: number,
248
+ colEnd: number,
249
+ getBindingAt: GetBindingAt
250
+ ): ScanHit[] {
251
+ const hits: ScanHit[] = []
252
+ for (let col = colStart; col < colEnd; col++) {
253
+ const binding = getBindingAt({ row, col })
254
+ if (!binding || !isVerticalExpandBinding(binding)) continue
255
+ hits.push({
256
+ addr: { row, col },
257
+ group: isRowGroupBinding(binding),
258
+ distance: Math.abs(col - originCol)
259
+ })
260
+ }
261
+ return hits
262
+ }
263
+
264
+ function scanHorizontalOnCol(
265
+ col: number,
266
+ originRow: number,
267
+ rowStart: number,
268
+ rowEnd: number,
269
+ getBindingAt: GetBindingAt
270
+ ): ScanHit[] {
271
+ const hits: ScanHit[] = []
272
+ for (let row = rowStart; row < rowEnd; row++) {
273
+ const binding = getBindingAt({ row, col })
274
+ if (!binding || !isHorizontalExpandBinding(binding)) continue
275
+ hits.push({
276
+ addr: { row, col },
277
+ group: isColGroupBinding(binding),
278
+ distance: Math.abs(row - originRow)
279
+ })
280
+ }
281
+ return hits
282
+ }
283
+
284
+ /**
285
+ * 行方向父格推断:
286
+ * 1. 同行向左:先找分组头,否则最近纵向扩展格
287
+ * 2. 上方各行整行扫描:每行先找分组头,再退回任意纵向扩展格
288
+ * 小计(preferGroup)禁止把明细当成父格
289
+ */
290
+ export function inferRowParentCandidate(
291
+ addr: CellAddress,
292
+ getBindingAt: GetBindingAt,
293
+ options?: InferParentOptions
294
+ ): CellAddress | null {
295
+ const preferGroup = options?.preferGroup === true
296
+
297
+ const sameRow = pickExpandHit(
298
+ scanVerticalOnRow(addr.row, addr.col, 0, addr.col, getBindingAt),
299
+ preferGroup
300
+ )
301
+ if (sameRow) return sameRow
302
+
303
+ for (let row = addr.row - 1; row >= 0; row--) {
304
+ const found = pickExpandHit(
305
+ scanVerticalOnRow(row, addr.col, 0, DEFAULT_SCAN_COLS, getBindingAt),
306
+ preferGroup
307
+ )
308
+ if (found) return found
309
+ }
310
+ return null
311
+ }
312
+
313
+ /**
314
+ * 列方向父格推断(与行方向对称):
315
+ * 1. 同列向上:先找列分组头,否则最近横向扩展格
316
+ * 2. 左侧各列整列扫描:每列先找列分组头
317
+ * 列小计(preferGroup)禁止把交叉格/明细当成列父格
318
+ */
319
+ export function inferColParentCandidate(
320
+ addr: CellAddress,
321
+ getBindingAt: GetBindingAt,
322
+ options?: InferParentOptions
323
+ ): CellAddress | null {
324
+ const preferGroup = options?.preferGroup === true
325
+
326
+ const sameCol = pickExpandHit(
327
+ scanHorizontalOnCol(addr.col, addr.row, 0, addr.row, getBindingAt),
328
+ preferGroup
329
+ )
330
+ if (sameCol) return sameCol
331
+
332
+ for (let col = addr.col - 1; col >= 0; col--) {
333
+ const found = pickExpandHit(
334
+ scanHorizontalOnCol(col, addr.row, 0, DEFAULT_SCAN_ROWS, getBindingAt),
335
+ preferGroup
336
+ )
337
+ if (found) return found
338
+ }
339
+ return null
340
+ }
341
+
342
+ function suggestsColumnSubtotal(above: ReportBinding | undefined): boolean {
343
+ if (!above) return false
344
+ return isHorizontalExpandBinding(above) || !!above.colParent || above.preset === 'cross'
345
+ }
346
+
347
+ function suggestsRowSubtotal(left: ReportBinding | undefined): boolean {
348
+ if (!left) return false
349
+ return isVerticalExpandBinding(left) || !!left.rowParent || left.preset === 'cross'
350
+ }
351
+
352
+ /**
353
+ * 按预设写入落格父格:明细/分组/交叉双方向推断;小计按「下方行小计 / 右侧列小计」只挂对应轴。
354
+ */
355
+ export function inferParentsForPreset(
356
+ addr: CellAddress,
357
+ preset: ReportPreset,
358
+ getBindingAt: GetBindingAt
359
+ ): Pick<ReportBinding, 'rowParent' | 'colParent'> {
360
+ if (preset === 'grandTotal') return {}
361
+
362
+ const preferGroup = preset === 'subtotal' || preset === 'cross'
363
+ const rowParent = inferRowParentCandidate(addr, getBindingAt, { preferGroup }) ?? undefined
364
+ const colParent = inferColParentCandidate(addr, getBindingAt, { preferGroup }) ?? undefined
365
+
366
+ if (preset !== 'subtotal') {
367
+ return { ...(rowParent ? { rowParent } : {}), ...(colParent ? { colParent } : {}) }
368
+ }
369
+
370
+ const above = addr.row > 0 ? getBindingAt({ row: addr.row - 1, col: addr.col }) : undefined
371
+ const left = addr.col > 0 ? getBindingAt({ row: addr.row, col: addr.col - 1 }) : undefined
372
+ const belowBand = addr.row > 0 && isExpansionBandRow(addr.row - 1, getBindingAt)
373
+ const rightOfBand = addr.col > 0 && isExpansionBandCol(addr.col - 1, getBindingAt)
374
+
375
+ const columnSubtotal =
376
+ suggestsColumnSubtotal(above) || (rightOfBand && !suggestsRowSubtotal(left))
377
+ const rowSubtotal = suggestsRowSubtotal(left) || (belowBand && !columnSubtotal)
378
+
379
+ const result: Pick<ReportBinding, 'rowParent' | 'colParent'> = {}
380
+ if (rowSubtotal && rowParent) result.rowParent = rowParent
381
+ if (columnSubtotal && colParent) result.colParent = colParent
382
+ if (!rowSubtotal && !columnSubtotal) {
383
+ if (belowBand && rowParent) result.rowParent = rowParent
384
+ else if (rightOfBand && colParent) result.colParent = colParent
385
+ else {
386
+ if (rowParent) result.rowParent = rowParent
387
+ if (colParent) result.colParent = colParent
388
+ }
389
+ }
390
+ return result
391
+ }
392
+
393
+ /** 从绑定字段推断预设;不匹配任何预设时返回 null(自定义) */
394
+ export function inferReportPreset(binding: ReportBinding): ReportPreset | null {
395
+ if (binding.preset) return binding.preset
396
+
397
+ if (binding.expand === 'none' && isReductionAggregate(binding.aggregate)) {
398
+ if (binding.rowParent && binding.colParent) return 'cross'
399
+ if (binding.rowParent || binding.colParent) return 'subtotal'
400
+ return 'grandTotal'
401
+ }
402
+ if (binding.aggregate === 'group' && (binding.expand === 'down' || binding.expand === 'right')) {
403
+ return 'groupHeader'
404
+ }
405
+ if (binding.aggregate === 'list' && (binding.expand === 'down' || binding.expand === 'right')) {
406
+ return 'detail'
407
+ }
408
+ return null
409
+ }
410
+
411
+ /** 默认明细 + 纵向扩展 */
412
+ export function createReportBinding(dataset: DatasetCatalogItem, fieldName: string): ReportBinding {
413
+ return {
414
+ dataset: dataset.id,
415
+ field: fieldName,
416
+ aggregate: 'list',
417
+ expand: 'down',
418
+ preset: 'detail',
419
+ sort: 'none',
420
+ conditionalRules: []
421
+ }
422
+ }
423
+
424
+ /** 设计地址 → A1 标签(支持多字母列) */
425
+ export function formatCellAddress(addr: CellAddress): string {
426
+ return formatAddress(addr)
427
+ }
428
+
429
+ /** A1 标签 → 设计地址;非法输入返回 null */
430
+ export function parseCellAddress(label: string): CellAddress | null {
431
+ return parseAddress(label)
432
+ }
433
+
434
+ /** 解析字段中文标签(找不到时回退字段名);可传入报表配置中的 label */
435
+ function resolveFieldLabel(
436
+ datasetId: string,
437
+ fieldName: string,
438
+ resolveLabel?: (datasetId: string, fieldName: string) => string
439
+ ): string {
440
+ const override = resolveLabel?.(datasetId, fieldName)
441
+ if (override) return override
442
+ const dataset = bindingCatalog.find((d) => d.id === datasetId)
443
+ const field = dataset?.fields.find((f) => f.name === fieldName)
444
+ return field?.label ?? fieldName
445
+ }
446
+
447
+ /** Binding Placeholder 分段文案(聚合标签 + 字段标签),供徽章分色渲染等场景使用 */
448
+ export function formatBindingPlaceholderParts(
449
+ binding: ReportBinding,
450
+ resolveLabel?: (datasetId: string, fieldName: string) => string
451
+ ): { tag: string; label: string } {
452
+ return {
453
+ tag: AGGREGATE_PLACEHOLDER_TAG[binding.aggregate],
454
+ label: resolveFieldLabel(binding.dataset, binding.field, resolveLabel)
455
+ }
456
+ }
457
+
458
+ /** Binding Placeholder 中文可读文案(如「分组 · 客户」) */
459
+ export function formatBindingPlaceholder(
460
+ binding: ReportBinding,
461
+ resolveLabel?: (datasetId: string, fieldName: string) => string
462
+ ): string {
463
+ const { tag, label } = formatBindingPlaceholderParts(binding, resolveLabel)
464
+ return `${tag} · ${label}`
465
+ }
466
+
467
+ /** 父格下拉候选文案(如「分组 · 客户 (A2)」) */
468
+ export function formatParentCandidateLabel(
469
+ addr: CellAddress,
470
+ binding: ReportBinding,
471
+ resolveLabel?: (datasetId: string, fieldName: string) => string
472
+ ): string {
473
+ return `${formatBindingPlaceholder(binding, resolveLabel)} (${formatCellAddress(addr)})`
474
+ }
@@ -0,0 +1,206 @@
1
+ import type { DatasetField, ParamValues } from './types'
2
+
3
+ /** 数据连接类型(ADR-0003 决策 3:收敛为 MySQL / PostgreSQL) */
4
+ export type ConnectionType = 'mysql' | 'postgresql'
5
+
6
+ /** 各连接类型的默认端口 */
7
+ export const DEFAULT_CONNECTION_PORTS: Record<ConnectionType, number> = {
8
+ mysql: 3306,
9
+ postgresql: 5432
10
+ }
11
+
12
+ /**
13
+ * 切换连接类型时解析端口:若当前端口仍是上一类型的默认值(或 0),则切到新类型默认端口;
14
+ * 用户已自定义的端口保持不变。
15
+ */
16
+ export function resolvePortOnTypeChange(
17
+ prevType: ConnectionType,
18
+ nextType: ConnectionType,
19
+ currentPort: number
20
+ ): number {
21
+ if (prevType === nextType) return currentPort
22
+ if (currentPort === DEFAULT_CONNECTION_PORTS[prevType] || currentPort === 0) {
23
+ return DEFAULT_CONNECTION_PORTS[nextType]
24
+ }
25
+ return currentPort
26
+ }
27
+
28
+ /**
29
+ * 数据连接(纯序列化对象,仅驻留内存)。
30
+ * 凭据持久化与安全存储由下游负责(ADR-0003 决策 4)。
31
+ */
32
+ export interface DataConnection {
33
+ id: string
34
+ label: string
35
+ type: ConnectionType
36
+ host: string
37
+ port: number
38
+ database: string
39
+ username: string
40
+ password: string
41
+ }
42
+
43
+ /** 业务错误(`200 + { ok: false, error }`)或客户端传输层错误 */
44
+ export interface ConnectorError {
45
+ code: string
46
+ message: string
47
+ }
48
+
49
+ /** 统一结果:成功携带 data;失败携带 error(连接器不抛异常,调用方只处理一条错误路径) */
50
+ export type Result<T> = { ok: true; data: T } | { ok: false; error: ConnectorError }
51
+
52
+ /** query 成功结果:字段 schema + 行记录(契约 `{ fields, rows }` 归一化后) */
53
+ export interface QueryResult {
54
+ fields: DatasetField[]
55
+ rows: Record<string, unknown>[]
56
+ }
57
+
58
+ /**
59
+ * 数据连接器(词汇表:Data Connector):test / describe / query 三方法镜像 HTTP 契约三端点。
60
+ * 真实数据库访问由下游后端以任意语言实现(BYO)。
61
+ */
62
+ export interface DataConnector {
63
+ test(connection: DataConnection): Promise<Result<void>>
64
+ describe(connection: DataConnection, sql: string): Promise<Result<DatasetField[]>>
65
+ query(connection: DataConnection, sql: string, values: ParamValues): Promise<Result<QueryResult>>
66
+ }
67
+
68
+ export interface CreateHttpConnectorOptions {
69
+ /** 契约端点前缀,如 `https://api.example.com/report`;三端点 = `{endpoint}/test|describe|query`(无版本段) */
70
+ endpoint: string
71
+ }
72
+
73
+ // ---- HTTP 契约响应形状(ADR-0003 决策 3)----
74
+ // 传输层错误用 HTTP 状态码;业务错误一律 `200 + { ok: false, error }`。
75
+
76
+ /** 契约字段 schema:`{ name, label?, type? }`(label 缺省视为 name,type 缺省视为 string) */
77
+ interface ContractField {
78
+ name: string
79
+ label?: string
80
+ type?: DatasetField['type']
81
+ }
82
+
83
+ interface ContractErrorBody {
84
+ ok: false
85
+ error: ConnectorError
86
+ }
87
+
88
+ type TestResponseBody = { ok: true } | ContractErrorBody
89
+
90
+ type DescribeResponseBody = { ok: true; fields: ContractField[] } | ContractErrorBody
91
+
92
+ type QueryResponseBody =
93
+ | { ok: true; fields: ContractField[]; rows: Record<string, unknown>[] }
94
+ | ContractErrorBody
95
+
96
+ /** 客户端错误:网络不可达 / 非 2xx / 响应不可解析(业务错误由服务端 200 透传) */
97
+ function badResponse(message: string): Result<never> {
98
+ return { ok: false, error: { code: 'BAD_RESPONSE', message } }
99
+ }
100
+
101
+ function isRecord(value: unknown): value is Record<string, unknown> {
102
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
103
+ }
104
+
105
+ function toConnectorError(value: unknown): ConnectorError {
106
+ if (isRecord(value)) {
107
+ const { code, message } = value
108
+ if (typeof code === 'string' && typeof message === 'string') {
109
+ return { code, message }
110
+ }
111
+ }
112
+ return { code: 'SERVER_ERROR', message: '服务端返回未知错误' }
113
+ }
114
+
115
+ /** 契约字段 → 内核 DatasetField(label 缺省回退 name,type 缺省 string) */
116
+ function normalizeField(field: ContractField): DatasetField {
117
+ return { name: field.name, label: field.label ?? field.name, type: field.type ?? 'string' }
118
+ }
119
+
120
+ /** 校验并归一化契约 fields;形状不合法返回 null */
121
+ function normalizeFields(fields: unknown): DatasetField[] | null {
122
+ if (!Array.isArray(fields)) return null
123
+ const normalized: DatasetField[] = []
124
+ for (const field of fields) {
125
+ if (!isRecord(field) || typeof field.name !== 'string') return null
126
+ normalized.push(normalizeField(field as unknown as ContractField))
127
+ }
128
+ return normalized
129
+ }
130
+
131
+ /**
132
+ * 创建 HTTP 数据连接器:三方法对应 `POST {endpoint}/test|describe|query`。
133
+ * - 请求体 JSON 序列化,`Content-Type: application/json`;
134
+ * - 网络异常与非 2xx(传输层错误)折叠为 `{ ok: false, error }`,不抛异常;
135
+ * - 业务错误(连接失败 / SQL 报错)由服务端以 `200 + { ok: false, error: { code, message } }` 返回并原样透传;
136
+ * - `describe` / `query` 的 fields 归一化为内核 `DatasetField`(label 回退 name,type 缺省 string)。
137
+ */
138
+ export function createHttpConnector({ endpoint }: CreateHttpConnectorOptions): DataConnector {
139
+ async function postJson<TBody>(path: string, body: unknown): Promise<Result<TBody>> {
140
+ let response: Response
141
+ try {
142
+ response = await fetch(`${endpoint}${path}`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify(body)
146
+ })
147
+ } catch (error) {
148
+ return {
149
+ ok: false,
150
+ error: {
151
+ code: 'NETWORK_ERROR',
152
+ message: error instanceof Error ? error.message : String(error)
153
+ }
154
+ }
155
+ }
156
+
157
+ if (!response.ok) {
158
+ return {
159
+ ok: false,
160
+ error: { code: `HTTP_${response.status}`, message: `请求失败(HTTP ${response.status})` }
161
+ }
162
+ }
163
+
164
+ let payload: unknown
165
+ try {
166
+ payload = await response.json()
167
+ } catch {
168
+ return badResponse('响应不是合法 JSON')
169
+ }
170
+
171
+ if (!isRecord(payload)) return badResponse('响应不是 JSON 对象')
172
+ return { ok: true, data: payload as TBody }
173
+ }
174
+
175
+ return {
176
+ async test(connection) {
177
+ const result = await postJson<TestResponseBody>('/test', { connection })
178
+ if (!result.ok) return result
179
+ if (result.data.ok === true) return { ok: true, data: undefined }
180
+ return { ok: false, error: toConnectorError(result.data.error) }
181
+ },
182
+
183
+ async describe(connection, sql) {
184
+ const result = await postJson<DescribeResponseBody>('/describe', { connection, sql })
185
+ if (!result.ok) return result
186
+ if (result.data.ok !== true) {
187
+ return { ok: false, error: toConnectorError(result.data.error) }
188
+ }
189
+ const fields = normalizeFields(result.data.fields)
190
+ if (!fields) return badResponse('describe 响应缺少合法的 fields 数组')
191
+ return { ok: true, data: fields }
192
+ },
193
+
194
+ async query(connection, sql, values) {
195
+ const result = await postJson<QueryResponseBody>('/query', { connection, sql, values })
196
+ if (!result.ok) return result
197
+ if (result.data.ok !== true) {
198
+ return { ok: false, error: toConnectorError(result.data.error) }
199
+ }
200
+ const fields = normalizeFields(result.data.fields)
201
+ if (!fields) return badResponse('query 响应缺少合法的 fields 数组')
202
+ if (!Array.isArray(result.data.rows)) return badResponse('query 响应缺少 rows 数组')
203
+ return { ok: true, data: { fields, rows: result.data.rows } }
204
+ }
205
+ }
206
+ }