@veltra/sheet 2.2.0 → 2.3.1

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 (86) hide show
  1. package/dist/components/report/binding-badge.js +45 -21
  2. package/dist/components/report/designer/col-widths.js +18 -4
  3. package/dist/components/report/designer/conditional-rules/helpers.js +3 -3
  4. package/dist/components/report/designer/float-panel.js +73 -36
  5. package/dist/components/report/designer/rule-row.js +111 -127
  6. package/dist/components/report/designer/rules-dialog.js +5 -22
  7. package/dist/components/report/hub-dataset-editor.js +16 -1
  8. package/dist/components/report/preview-grid-size.js +32 -0
  9. package/dist/components/report/report-designer.js +75 -25
  10. package/dist/components/report/report-viewer.js +38 -25
  11. package/dist/components/report/style2.css +44 -39
  12. package/dist/components/report/use-report-designer.js +34 -12
  13. package/dist/components/report/use-report-viewer.js +6 -1
  14. package/dist/components/sheet/popups/border-popup.js +30 -2
  15. package/dist/components/sheet/popups/fill-color-popup.js +4 -3
  16. package/dist/components/sheet/popups/font-color-popup.js +2 -2
  17. package/dist/components/sheet/popups/font-size-popup.js +2 -2
  18. package/dist/components/sheet/sheet-context-menu.js +6 -3
  19. package/dist/components/sheet/sheet.js +8 -0
  20. package/dist/components/sheet/use-sheet-grid.js +14 -4
  21. package/dist/components/sheet/use-sheet-state.js +3 -2
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.js +2 -2
  24. package/dist/report/binding.d.ts +49 -11
  25. package/dist/report/binding.js +186 -29
  26. package/dist/report/connector.js +1 -1
  27. package/dist/report/export-xlsx.d.ts +3 -5
  28. package/dist/report/export-xlsx.js +7 -10
  29. package/dist/report/index.d.ts +2 -2
  30. package/dist/report/render/aggregate.js +20 -8
  31. package/dist/report/template.d.ts +8 -4
  32. package/dist/report/template.js +5 -2
  33. package/dist/tools/apply-style.js +29 -0
  34. package/dist/tools/builtin.js +17 -17
  35. package/dist/tools/context.d.ts +38 -5
  36. package/dist/tools/context.js +38 -1
  37. package/dist/types/report-designer.d.ts +2 -0
  38. package/dist/types/report-viewer.d.ts +2 -2
  39. package/dist/types/sheet.d.ts +4 -0
  40. package/package.json +15 -6
  41. package/src/components/report/__test__/conditional-rules-helpers.test.ts +1 -0
  42. package/src/components/report/__test__/preview-grid-size.test.ts +63 -0
  43. package/src/components/report/__test__/report-designer.test.ts +76 -0
  44. package/src/components/report/__test__/report-viewer.test.ts +24 -1
  45. package/src/components/report/__test__/use-report-designer.test.ts +142 -10
  46. package/src/components/report/binding-badge.ts +39 -20
  47. package/src/components/report/designer/col-widths.ts +34 -11
  48. package/src/components/report/designer/conditional-rules/helpers.ts +6 -3
  49. package/src/components/report/designer/float-panel.vue +71 -27
  50. package/src/components/report/designer/rule-row.vue +67 -67
  51. package/src/components/report/designer/rules-dialog.vue +3 -19
  52. package/src/components/report/hub-dataset-editor.vue +19 -1
  53. package/src/components/report/preview-grid-size.ts +33 -0
  54. package/src/components/report/report-designer.vue +79 -15
  55. package/src/components/report/report-viewer.vue +37 -29
  56. package/src/components/report/style-designer.scss +45 -41
  57. package/src/components/report/use-report-designer.ts +63 -14
  58. package/src/components/report/use-report-viewer.ts +11 -2
  59. package/src/components/sheet/__test__/sheet-component.test.ts +37 -0
  60. package/src/components/sheet/__test__/sheet-context-menu.test.ts +3 -2
  61. package/src/components/sheet/popups/border-popup.vue +31 -3
  62. package/src/components/sheet/popups/fill-color-popup.vue +5 -4
  63. package/src/components/sheet/popups/font-color-popup.vue +2 -2
  64. package/src/components/sheet/popups/font-size-popup.vue +2 -2
  65. package/src/components/sheet/sheet-context-menu.ts +8 -2
  66. package/src/components/sheet/sheet.vue +2 -0
  67. package/src/components/sheet/use-sheet-grid.ts +17 -3
  68. package/src/components/sheet/use-sheet-state.ts +8 -3
  69. package/src/report/__test__/binding.test.ts +103 -1
  70. package/src/report/__test__/connector.test.ts +21 -0
  71. package/src/report/__test__/export-xlsx.test.ts +4 -4
  72. package/src/report/__test__/render.test.ts +102 -0
  73. package/src/report/__test__/template.test.ts +11 -0
  74. package/src/report/binding.ts +278 -47
  75. package/src/report/connector.ts +3 -2
  76. package/src/report/export-xlsx.ts +9 -14
  77. package/src/report/render/aggregate.ts +29 -8
  78. package/src/report/template.ts +8 -4
  79. package/src/tools/__test__/apply-style.test.ts +129 -0
  80. package/src/tools/apply-style.ts +41 -0
  81. package/src/tools/builtin.ts +18 -18
  82. package/src/tools/context.ts +67 -4
  83. package/src/tools/index.ts +1 -0
  84. package/src/types/report-designer.ts +2 -0
  85. package/src/types/report-viewer.ts +2 -2
  86. package/src/types/sheet.ts +4 -0
@@ -258,6 +258,43 @@ describe('USheet 组件', () => {
258
258
  expect(gridAfter).not.toBe(gridBefore)
259
259
  })
260
260
 
261
+ it('showRowHeader/showColHeader 为 false 时隐藏行号与列头', async () => {
262
+ const workbook = createWorkbook()
263
+ const exposed: { value: SheetExposed | undefined } = { value: undefined }
264
+ mount(
265
+ () => ({ workbook, rows: 8, cols: 4, showRowHeader: false, showColHeader: false }),
266
+ exposed
267
+ )
268
+ await nextTick()
269
+
270
+ workbook.activeSheet.setCellValue({ row: 0, col: 0 }, 'A1')
271
+ await nextTick()
272
+ const table = exposed.value!.getGrid()!.getTable()
273
+ expect(table.options.showHeader).toBe(false)
274
+ expect(table.options.rowSeriesNumber).toBeUndefined()
275
+ expect(table.columnHeaderLevelCount).toBe(0)
276
+ expect(table.rowCount).toBe(8)
277
+ expect(table.getCellValue(0, 0)).toBe('A1')
278
+ })
279
+
280
+ it('删除行后渲染行数跟随模型收缩(不被 props.rows 撑回)', async () => {
281
+ const workbook = createWorkbook()
282
+ const exposed: { value: SheetExposed | undefined } = { value: undefined }
283
+ mount(() => ({ workbook, rows: 10, cols: 6 }), exposed)
284
+ await nextTick()
285
+
286
+ const sheet = exposed.value!.getActiveSheet()
287
+ expect(sheet.rows).toBe(10)
288
+ expect(exposed.value!.getGrid()!.getTable().rowCount).toBe(11) // 列头 + 10
289
+
290
+ sheet.deleteRows(0, 2)
291
+ await nextTick()
292
+ await nextTick()
293
+
294
+ expect(sheet.rows).toBe(8)
295
+ expect(exposed.value!.getGrid()!.getTable().rowCount).toBe(9) // 列头 + 8
296
+ })
297
+
261
298
  it('resolveCellStyle prop 传入 SheetGrid 视口渲染', async () => {
262
299
  const workbook = createWorkbook()
263
300
  const sheet = workbook.activeSheet
@@ -36,8 +36,9 @@ describe('sheet-context-menu helpers', () => {
36
36
  expect(isColInSelection(ranges, 6)).toBe(false)
37
37
  })
38
38
 
39
- it('resolveRenderSize = max(props, sheet)', () => {
40
- expect(resolveRenderSize(100, 26, 120, 10)).toEqual({ rows: 120, cols: 26 })
39
+ it('resolveRenderSize:模型已声明时以模型为准(可低于 props)', () => {
40
+ expect(resolveRenderSize(100, 26, 120, 10)).toEqual({ rows: 120, cols: 10 })
41
+ expect(resolveRenderSize(100, 26, 29, 26)).toEqual({ rows: 29, cols: 26 })
41
42
  expect(resolveRenderSize(undefined, undefined, 0, 0)).toEqual({ rows: 100, cols: 26 })
42
43
  })
43
44
  })
@@ -40,7 +40,8 @@ import { buildBorderPresetItems } from '@veltra/sheet-core/core/style/border-pre
40
40
  import {
41
41
  BORDER_STYLE_WIDTH,
42
42
  type BorderEdge,
43
- type BorderLineStyle
43
+ type BorderLineStyle,
44
+ type CellStylePatch
44
45
  } from '@veltra/sheet-core/core/style/types'
45
46
  import { bem } from '@veltra/utils'
46
47
  import { ref } from 'vue'
@@ -63,6 +64,7 @@ defineOptions({ name: 'USheetBorderPopup' })
63
64
  * 预设补丁由 core/style/border-presets 生成(含邻居格共享边同步,对齐
64
65
  * Excel「写入时同步、最近设置生效」);面板打开期间的写入由
65
66
  * use-tool-popup 事务包裹为一个 undo 单元。
67
+ * 整行/整列选区下,均匀预设(all/none/单侧)走 applyStyle → 行列默认样式。
66
68
  */
67
69
  const props = defineProps<{
68
70
  /** 工具上下文(写入走命令系统,天然可撤销) */
@@ -75,7 +77,27 @@ const cls = bem('sheet')
75
77
  const borderLineStyle = ref<BorderLineStyle>('thin')
76
78
  const borderColor = ref('#000000')
77
79
 
78
- /** 应用边框预设:一次 executeCommand(items 批量,含邻居同步补丁)= 一个 undo 单元 */
80
+ /** 均匀边框补丁(可写进行/列默认样式);outer/inner 几何相关 → null */
81
+ function uniformBorderPatch(preset: BorderPresetId, edge: BorderEdge): CellStylePatch | null {
82
+ switch (preset) {
83
+ case 'all':
84
+ return { border: { top: edge, right: edge, bottom: edge, left: edge } }
85
+ case 'none':
86
+ return { border: { top: null, right: null, bottom: null, left: null } }
87
+ case 'top':
88
+ return { border: { top: edge } }
89
+ case 'bottom':
90
+ return { border: { bottom: edge } }
91
+ case 'left':
92
+ return { border: { left: edge } }
93
+ case 'right':
94
+ return { border: { right: edge } }
95
+ default:
96
+ return null
97
+ }
98
+ }
99
+
100
+ /** 应用边框预设:整行/整列均匀预设走 applyStyle;其余逐格批量(含邻居同步) */
79
101
  function applyBorderPreset(preset: BorderPresetId): void {
80
102
  const range = currentRange(props.context)
81
103
  if (!range) return
@@ -84,8 +106,14 @@ function applyBorderPreset(preset: BorderPresetId): void {
84
106
  width: BORDER_STYLE_WIDTH[borderLineStyle.value],
85
107
  color: borderColor.value
86
108
  }
109
+ const target = props.context.resolveStyleTarget(range)
110
+ const uniform = target !== 'cell' ? uniformBorderPatch(preset, edge) : null
111
+ if (uniform) {
112
+ props.context.applyStyle(range, uniform)
113
+ return
114
+ }
87
115
  const items: SetCellStyleItem[] = buildBorderPresetItems(range, preset, edge, (addr) =>
88
- props.context.getCellStyle(addr)
116
+ props.context.getEffectiveStyle(addr)
89
117
  ).map(({ addr, patch }) => ({ addr, partial: patch }))
90
118
  if (items.length === 0) return
91
119
  props.context.executeCommand('sheet.command.set-cell-style', { items })
@@ -21,19 +21,20 @@ const props = defineProps<{
21
21
  }>()
22
22
 
23
23
  /** 填充颜色('' = 无填充) */
24
- const fillColor = ref('')
24
+ const fillColor = ref<string>()
25
25
 
26
26
  // 打开时初始化为活动格当前填充色
27
27
  onMounted(() => {
28
28
  const active = props.context.getSelection().activeCell
29
- const style = active ? props.context.getCellStyle(active) : undefined
29
+ const style = active ? props.context.getEffectiveStyle(active) : undefined
30
30
  fillColor.value = style?.fill?.color ?? ''
31
31
  })
32
32
 
33
33
  /** 填充颜色变化('' = 无填充:清除 fill 保留边框) */
34
- function applyFillColor(color: string): void {
34
+ function applyFillColor(color?: string): void {
35
+ fillColor.value = color
35
36
  const range = currentRange(props.context)
36
37
  if (!range) return
37
- props.context.setCellStyle(range, color ? { fill: { color } } : { fill: {} })
38
+ props.context.applyStyle(range, color ? { fill: { color } } : { fill: {} })
38
39
  }
39
40
  </script>
@@ -26,7 +26,7 @@ const fontColor = ref('')
26
26
  // 打开时初始化为活动格当前字体色
27
27
  onMounted(() => {
28
28
  const active = props.context.getSelection().activeCell
29
- const style = active ? props.context.getCellStyle(active) : undefined
29
+ const style = active ? props.context.getEffectiveStyle(active) : undefined
30
30
  fontColor.value = style?.font?.color ?? ''
31
31
  })
32
32
 
@@ -34,6 +34,6 @@ onMounted(() => {
34
34
  function applyFontColor(color: string): void {
35
35
  const range = currentRange(props.context)
36
36
  if (!range) return
37
- props.context.setCellStyle(range, color ? { font: { color } } : { font: { color: null } })
37
+ props.context.applyStyle(range, color ? { font: { color } } : { font: { color: null } })
38
38
  }
39
39
  </script>
@@ -39,7 +39,7 @@ const currentSize = ref<number | undefined>(undefined)
39
39
 
40
40
  onMounted(() => {
41
41
  const active = props.context.getSelection().activeCell
42
- const style = active ? props.context.getCellStyle(active) : undefined
42
+ const style = active ? props.context.getEffectiveStyle(active) : undefined
43
43
  currentSize.value = style?.font?.size
44
44
  })
45
45
 
@@ -47,6 +47,6 @@ function applyFontSize(size: number): void {
47
47
  const range = currentRange(props.context)
48
48
  if (!range) return
49
49
  currentSize.value = size
50
- props.context.setCellStyle(range, { font: { size } })
50
+ props.context.applyStyle(range, { font: { size } })
51
51
  }
52
52
  </script>
@@ -39,14 +39,20 @@ export function wholeColRange(col: number, rowCount: number): CellRange {
39
39
  return createRange({ row: 0, col }, { row: Math.max(rowCount - 1, 0), col })
40
40
  }
41
41
 
42
- /** 渲染尺寸 = max(props, sheet.rows/cols),与 SheetGrid 一致 */
42
+ /**
43
+ * 渲染尺寸:模型已声明时以模型为准(删行可低于 props);未声明时回落 props。
44
+ * 与 SheetGrid / useSheetGrid 一致——避免 ensureTableSize(props) 把删行结果撑回。
45
+ */
43
46
  export function resolveRenderSize(
44
47
  propsRows: number | undefined,
45
48
  propsCols: number | undefined,
46
49
  sheetRows: number,
47
50
  sheetCols: number
48
51
  ): { rows: number; cols: number } {
49
- return { rows: Math.max(propsRows ?? 100, sheetRows), cols: Math.max(propsCols ?? 26, sheetCols) }
52
+ return {
53
+ rows: sheetRows > 0 ? sheetRows : (propsRows ?? 100),
54
+ cols: sheetCols > 0 ? sheetCols : (propsCols ?? 26)
55
+ }
50
56
  }
51
57
 
52
58
  function insertCountRender(options: {
@@ -103,6 +103,8 @@ const props = withDefaults(defineProps<SheetProps>(), {
103
103
  showToolbar: true,
104
104
  showFormulaBar: true,
105
105
  showTabs: true,
106
+ showRowHeader: true,
107
+ showColHeader: true,
106
108
  readonly: false
107
109
  })
108
110
 
@@ -109,12 +109,16 @@ export function useSheetGrid(options: UseSheetGridOptions) {
109
109
  const el = document.createElement('div')
110
110
  el.className = 'u-sheet__grid-instance'
111
111
  host.appendChild(el)
112
+ // 模型已声明尺寸时传入模型值,避免删行后被 props 经 ensureTableSize 撑回
113
+ const { rows, cols } = resolveRenderSize(props.rows, props.cols, sheet.rows, sheet.cols)
112
114
  const grid = new SheetGrid({
113
115
  container: el,
114
116
  sheet,
115
- rows: props.rows,
116
- cols: props.cols,
117
+ rows,
118
+ cols,
117
119
  readonly: props.readonly,
120
+ showRowHeader: props.showRowHeader,
121
+ showColHeader: props.showColHeader,
118
122
  resolveDisplayValue: props.resolveDisplayValue,
119
123
  resolveCellStyle: props.resolveCellStyle,
120
124
  resolveCellRenderer: props.resolveCellRenderer,
@@ -237,7 +241,17 @@ export function useSheetGrid(options: UseSheetGridOptions) {
237
241
 
238
242
  onMounted(activateGrid)
239
243
 
240
- watch(() => [props.rows, props.cols, props.readonly], rebuildGrid)
244
+ // props 尺寸显式增大时先扩张模型,再重建(删行缩小不走这条路径)
245
+ watch(
246
+ () =>
247
+ [props.rows, props.cols, props.readonly, props.showRowHeader, props.showColHeader] as const,
248
+ ([nextRows, nextCols]) => {
249
+ const sheet = getActiveSheet()
250
+ if (nextRows != null) sheet.ensureTableSize(nextRows, 0)
251
+ if (nextCols != null) sheet.ensureTableSize(0, nextCols)
252
+ rebuildGrid()
253
+ }
254
+ )
241
255
 
242
256
  onBeforeUnmount(() => {
243
257
  invalidateAll()
@@ -5,6 +5,7 @@ import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vu
5
5
  import { createSheetContext } from '../../tools/context'
6
6
  import { defaultToolRegistry } from '../../tools/registry'
7
7
  import type { SheetProps } from '../../types'
8
+ import { resolveRenderSize } from './sheet-context-menu'
8
9
 
9
10
  /**
10
11
  * useSheetState 与宿主组件的协作钩子。
@@ -44,10 +45,14 @@ export function useSheetState(props: SheetProps, emit: Emit, hooks: SheetStateHo
44
45
  () => sheetList.value[activeIndex.value] ?? workbook.value.activeSheet
45
46
  )
46
47
 
47
- /** 工具上下文:动态解析活动 sheet 与当前工作簿,tab 切换 / workbook prop 切换后自动指向最新值 */
48
+ /** 工具上下文:动态解析活动 sheet / 工作簿 / 渲染尺寸,tab 切换后自动指向最新值 */
48
49
  const context = createSheetContext(
49
50
  () => activeSheet.value,
50
- () => workbook.value
51
+ () => workbook.value,
52
+ {
53
+ resolveGridSize: () =>
54
+ resolveRenderSize(props.rows, props.cols, activeSheet.value.rows, activeSheet.value.cols)
55
+ }
51
56
  )
52
57
 
53
58
  /** 工具栏状态版本号:visible/disabled/active 是 (ctx) => boolean 纯函数,状态源变化时 bump 触发重算 */
@@ -80,7 +85,7 @@ export function useSheetState(props: SheetProps, emit: Emit, hooks: SheetStateHo
80
85
  // 整表内容替换(导入 / undo/redo 回放):restore 静默不发 cell-change,
81
86
  // 状态源需 bump(grid 层自行订阅 content-reset 全量刷新,此处不重建)
82
87
  sheet.on('content-reset', scheduleBump),
83
- // 行列插入/删除 → 重建网格(渲染行列数 = max(props, sheet.rows/cols),
88
+ // 行列插入/删除 → 重建网格(渲染行列数跟随模型 sheet.rows/cols,
84
89
  // 数据/选区/冻结/行高随重建恢复;低频操作直接重建)
85
90
  sheet.on('structure-change', hooks.rebuildGrid)
86
91
  ]
@@ -6,11 +6,15 @@ import {
6
6
  createReportBinding,
7
7
  formatBindingPlaceholder,
8
8
  formatCellAddress,
9
+ inferColParentCandidate,
9
10
  inferDropPreset,
11
+ inferParentsForPreset,
10
12
  inferReportPreset,
13
+ inferRowParentCandidate,
11
14
  isExpansionBandRow,
12
15
  parseCellAddress,
13
16
  presetBindingPatch,
17
+ presetStillApplies,
14
18
  setBindingCatalog
15
19
  } from '../binding'
16
20
  import type { DatasetCatalogItem, ReportBinding } from '../types'
@@ -180,7 +184,32 @@ describe('report binding', () => {
180
184
  expand: 'none',
181
185
  rowParent: { row: 1, col: 0 }
182
186
  })
183
- ).toBeNull()
187
+ ).toBe('subtotal')
188
+
189
+ expect(
190
+ inferReportPreset({
191
+ dataset: 'orders',
192
+ field: 'amount',
193
+ aggregate: 'sum',
194
+ expand: 'none',
195
+ colParent: { row: 0, col: 1 }
196
+ })
197
+ ).toBe('subtotal')
198
+ })
199
+
200
+ it('presetStillApplies 同族内改聚合仍成立', () => {
201
+ const subtotal: ReportBinding = {
202
+ dataset: 'orders',
203
+ field: 'amount',
204
+ preset: 'subtotal',
205
+ aggregate: 'avg',
206
+ expand: 'none',
207
+ rowParent: { row: 1, col: 0 }
208
+ }
209
+ expect(presetStillApplies(subtotal)).toBe(true)
210
+
211
+ const broken = { ...subtotal, aggregate: 'list' as const, expand: 'down' as const }
212
+ expect(presetStillApplies(broken)).toBe(false)
184
213
  })
185
214
 
186
215
  it('inferDropPreset / isExpansionBandRow 落格推断', () => {
@@ -200,6 +229,79 @@ describe('report binding', () => {
200
229
  expect(inferDropPreset({ row: 2, col: 0 }, 'string', getBindingAt)).toBe('detail')
201
230
  })
202
231
 
232
+ it('inferDropPreset:数值字段落在横向展开带正右相邻列也推断为小计', () => {
233
+ const colGroup = createReportBinding(ORDERS_DATASET, 'region')
234
+ colGroup.aggregate = 'group'
235
+ colGroup.expand = 'right'
236
+
237
+ const getBindingAt = (addr: CellAddress) => {
238
+ if (addr.row === 0 && addr.col === 1) return colGroup
239
+ return undefined
240
+ }
241
+
242
+ expect(inferDropPreset({ row: 0, col: 2 }, 'number', getBindingAt)).toBe('subtotal')
243
+ expect(inferDropPreset({ row: 0, col: 2 }, 'string', getBindingAt)).toBe('detail')
244
+ })
245
+
246
+ it('inferRowParentCandidate:同行向左优先分组头', () => {
247
+ const group = createReportBinding(ORDERS_DATASET, 'customer')
248
+ group.aggregate = 'group'
249
+ group.expand = 'down'
250
+ const detail = createReportBinding(ORDERS_DATASET, 'orderNo')
251
+
252
+ const getBindingAt = (addr: CellAddress) => {
253
+ if (addr.row === 1 && addr.col === 0) return group
254
+ if (addr.row === 1 && addr.col === 1) return detail
255
+ return undefined
256
+ }
257
+
258
+ expect(inferRowParentCandidate({ row: 1, col: 2 }, getBindingAt)).toEqual({ row: 1, col: 0 })
259
+ })
260
+
261
+ it('inferRowParentCandidate:下方跨列小计推到行分组头而不是同列 list', () => {
262
+ const group = createReportBinding(ORDERS_DATASET, 'customer')
263
+ group.aggregate = 'group'
264
+ group.expand = 'down'
265
+ const detail = createReportBinding(ORDERS_DATASET, 'amount')
266
+
267
+ const getBindingAt = (addr: CellAddress) => {
268
+ if (addr.row === 1 && addr.col === 0) return group
269
+ if (addr.row === 1 && addr.col === 2) return detail
270
+ return undefined
271
+ }
272
+
273
+ expect(
274
+ inferRowParentCandidate({ row: 2, col: 2 }, getBindingAt, { preferGroup: true })
275
+ ).toEqual({ row: 1, col: 0 })
276
+ expect(inferParentsForPreset({ row: 2, col: 2 }, 'subtotal', getBindingAt)).toEqual({
277
+ rowParent: { row: 1, col: 0 }
278
+ })
279
+ })
280
+
281
+ it('inferColParentCandidate:右侧列小计同列向上推到列分组头', () => {
282
+ const colGroup = createReportBinding(ORDERS_DATASET, 'region')
283
+ colGroup.aggregate = 'group'
284
+ colGroup.expand = 'right'
285
+ const cross = createReportBinding(ORDERS_DATASET, 'amount')
286
+ cross.preset = 'cross'
287
+ cross.aggregate = 'sum'
288
+ cross.expand = 'none'
289
+ cross.colParent = { row: 0, col: 1 }
290
+
291
+ const getBindingAt = (addr: CellAddress) => {
292
+ if (addr.row === 0 && addr.col === 1) return colGroup
293
+ if (addr.row === 1 && addr.col === 1) return cross
294
+ return undefined
295
+ }
296
+
297
+ expect(
298
+ inferColParentCandidate({ row: 2, col: 1 }, getBindingAt, { preferGroup: true })
299
+ ).toEqual({ row: 0, col: 1 })
300
+ expect(inferParentsForPreset({ row: 2, col: 1 }, 'subtotal', getBindingAt)).toEqual({
301
+ colParent: { row: 0, col: 1 }
302
+ })
303
+ })
304
+
203
305
  it('formatCellAddress / parseCellAddress 支持多字母列互逆', () => {
204
306
  const single = { row: 2, col: 1 }
205
307
  expect(formatCellAddress(single)).toBe('B3')
@@ -84,6 +84,27 @@ describe('createHttpConnector', () => {
84
84
  expect(calls[0]!.body).toEqual({ connection: CONNECTION, sql: SQL })
85
85
  })
86
86
 
87
+ it('describe:服务端返回 label 时保留中文显示名', async () => {
88
+ stubFetch(async () => ({
89
+ ok: true,
90
+ fields: [
91
+ { name: 'customer', label: '客户名称', type: 'string' },
92
+ { name: 'amount', label: '金额', type: 'number' }
93
+ ]
94
+ }))
95
+ const connector = createHttpConnector({ endpoint: ENDPOINT })
96
+
97
+ const result = await connector.describe(CONNECTION, SQL)
98
+
99
+ expect(result).toEqual({
100
+ ok: true,
101
+ data: [
102
+ { name: 'customer', label: '客户名称', type: 'string' },
103
+ { name: 'amount', label: '金额', type: 'number' }
104
+ ]
105
+ })
106
+ })
107
+
87
108
  it('query:POST {endpoint}/query,请求体 { connection, sql, values },返回 fields + rows', async () => {
88
109
  const calls = stubFetch(async () => ({
89
110
  ok: true,
@@ -60,10 +60,10 @@ function buildFilledSheet(): Sheet {
60
60
 
61
61
  describe('exportFilledReportXlsx', () => {
62
62
  it('导出保真 XLSX:值 / 表头样式 / 条件样式打平后随快照导出', async () => {
63
- const buffer = await exportFilledReportXlsx(buildFilledSheet(), [
64
- [0, 120],
65
- [1, 140]
66
- ])
63
+ const filled = buildFilledSheet()
64
+ filled.setColWidth(0, 120)
65
+ filled.setColWidth(1, 140)
66
+ const buffer = await exportFilledReportXlsx(filled)
67
67
 
68
68
  // 合法 ZIP(xlsx)字节
69
69
  expect(buffer[0]).toBe(0x50)
@@ -725,4 +725,106 @@ describe('renderReport', () => {
725
725
  ])
726
726
  )
727
727
  })
728
+
729
+ it('跨列行小计按组求和,不是全表合计', () => {
730
+ const sheet = new Sheet()
731
+ sheet.setCells([{ addr: { row: 0, col: 0 }, data: { v: '客户' } }])
732
+ const groupParent = { row: 1, col: 0 }
733
+
734
+ const customerGroup = createReportBinding(ORDERS_DATASET, 'customer')
735
+ customerGroup.preset = 'groupHeader'
736
+ customerGroup.aggregate = 'group'
737
+ customerGroup.expand = 'down'
738
+ sheet.setCellMeta(groupParent, REPORT_META_NAMESPACE, customerGroup)
739
+
740
+ const orderNo = createReportBinding(ORDERS_DATASET, 'orderNo')
741
+ orderNo.rowParent = groupParent
742
+ sheet.setCellMeta({ row: 1, col: 1 }, REPORT_META_NAMESPACE, orderNo)
743
+
744
+ const amount = createReportBinding(ORDERS_DATASET, 'amount')
745
+ amount.rowParent = groupParent
746
+ sheet.setCellMeta({ row: 1, col: 2 }, REPORT_META_NAMESPACE, amount)
747
+
748
+ const subtotal = createReportBinding(ORDERS_DATASET, 'amount')
749
+ subtotal.preset = 'subtotal'
750
+ subtotal.aggregate = 'sum'
751
+ subtotal.expand = 'none'
752
+ subtotal.rowParent = groupParent
753
+ sheet.setCellMeta({ row: 2, col: 2 }, REPORT_META_NAMESPACE, subtotal)
754
+
755
+ const filled = renderReport(sheet.snapshot(), MOCK_DATA_RECORDS)
756
+ expect(cellValue(filled, 5, 2)).toBe(630)
757
+ expect(cellValue(filled, 10, 2)).toBe(1050)
758
+ expect(cellValue(filled, 5, 2)).not.toBe(4180)
759
+ })
760
+
761
+ it('跨列行小计误挂明细时仍按分组祖先过滤', () => {
762
+ const sheet = new Sheet()
763
+ sheet.setCells([{ addr: { row: 0, col: 0 }, data: { v: '客户' } }])
764
+ const groupParent = { row: 1, col: 0 }
765
+ const listAddr = { row: 1, col: 2 }
766
+
767
+ const customerGroup = createReportBinding(ORDERS_DATASET, 'customer')
768
+ customerGroup.preset = 'groupHeader'
769
+ customerGroup.aggregate = 'group'
770
+ customerGroup.expand = 'down'
771
+ sheet.setCellMeta(groupParent, REPORT_META_NAMESPACE, customerGroup)
772
+
773
+ const amount = createReportBinding(ORDERS_DATASET, 'amount')
774
+ amount.rowParent = groupParent
775
+ sheet.setCellMeta(listAddr, REPORT_META_NAMESPACE, amount)
776
+
777
+ const subtotal = createReportBinding(ORDERS_DATASET, 'amount')
778
+ subtotal.preset = 'subtotal'
779
+ subtotal.aggregate = 'sum'
780
+ subtotal.expand = 'none'
781
+ subtotal.rowParent = listAddr
782
+ sheet.setCellMeta({ row: 2, col: 2 }, REPORT_META_NAMESPACE, subtotal)
783
+
784
+ const filled = renderReport(sheet.snapshot(), MOCK_DATA_RECORDS)
785
+ expect(cellValue(filled, 5, 2)).toBe(630)
786
+ expect(cellValue(filled, 10, 2)).toBe(1050)
787
+ })
788
+
789
+ it('交叉列小计仅 colParent 时按列分组求和,不是全表合计', () => {
790
+ const sheet = new Sheet()
791
+ const colGroupAddr = { row: 0, col: 1 }
792
+ const rowGroupAddr = { row: 1, col: 0 }
793
+
794
+ const categoryGroup = createReportBinding(SALES_MATRIX_DATASET, 'category')
795
+ categoryGroup.preset = 'groupHeader'
796
+ categoryGroup.aggregate = 'group'
797
+ categoryGroup.expand = 'right'
798
+ sheet.setCellMeta(colGroupAddr, REPORT_META_NAMESPACE, categoryGroup)
799
+
800
+ const regionGroup = createReportBinding(SALES_MATRIX_DATASET, 'region')
801
+ regionGroup.preset = 'groupHeader'
802
+ regionGroup.aggregate = 'group'
803
+ regionGroup.expand = 'down'
804
+ sheet.setCellMeta(rowGroupAddr, REPORT_META_NAMESPACE, regionGroup)
805
+
806
+ const cross = createReportBinding(SALES_MATRIX_DATASET, 'amount')
807
+ cross.preset = 'cross'
808
+ cross.aggregate = 'sum'
809
+ cross.expand = 'none'
810
+ cross.rowParent = rowGroupAddr
811
+ cross.colParent = colGroupAddr
812
+ sheet.setCellMeta({ row: 1, col: 1 }, REPORT_META_NAMESPACE, cross)
813
+
814
+ const colSubtotal = createReportBinding(SALES_MATRIX_DATASET, 'amount')
815
+ colSubtotal.preset = 'subtotal'
816
+ colSubtotal.aggregate = 'sum'
817
+ colSubtotal.expand = 'none'
818
+ colSubtotal.colParent = colGroupAddr
819
+ sheet.setCellMeta({ row: 2, col: 1 }, REPORT_META_NAMESPACE, colSubtotal)
820
+
821
+ const filled = renderReport(sheet.snapshot(), { 'sales-matrix': SALES_MATRIX_ROWS })
822
+ const officeTotal = SALES_MATRIX_ROWS.filter((row) => row.category === '办公设备').reduce(
823
+ (sum, row) => sum + (row.amount as number),
824
+ 0
825
+ )
826
+ const grandTotal = SALES_MATRIX_ROWS.reduce((sum, row) => sum + (row.amount as number), 0)
827
+ expect(cellValue(filled, 5, 1)).toBe(officeTotal)
828
+ expect(cellValue(filled, 5, 1)).not.toBe(grandTotal)
829
+ })
728
830
  })
@@ -105,6 +105,17 @@ describe('ReportTemplate version', () => {
105
105
  expect(template.version).toBe(REPORT_TEMPLATE_VERSION)
106
106
  })
107
107
 
108
+ it('createReportTemplate 继承 snapshot.colWidths', () => {
109
+ const sheet = new Sheet()
110
+ sheet.setColWidth(0, 120)
111
+ sheet.setColWidth(2, 90)
112
+ const template = createReportTemplate(sheet.snapshot(), [ORDERS_DATASET])
113
+ expect(template.colWidths).toEqual([
114
+ [0, 120],
115
+ [2, 90]
116
+ ])
117
+ })
118
+
108
119
  it('version 缺失时 assertCompatibleTemplateVersion 抛可读错误', () => {
109
120
  const sheet = new Sheet()
110
121
  const template = sheet.snapshot() as ReportTemplate