@veltra/sheet 2.3.4 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/components/report/binding-badge.js +6 -0
  2. package/dist/components/report/designer/drill-dialog.js +258 -0
  3. package/dist/components/report/designer/float-panel.js +26 -14
  4. package/dist/components/report/print.js +39 -0
  5. package/dist/components/report/report-designer.js +56 -21
  6. package/dist/components/report/report-viewer.js +173 -44
  7. package/dist/components/report/style2.css +91 -0
  8. package/dist/components/report/use-report-designer.js +1 -0
  9. package/dist/components/report/use-report-viewer.js +88 -3
  10. package/dist/index.d.ts +4 -2
  11. package/dist/index.js +3 -1
  12. package/dist/report/drill.d.ts +48 -0
  13. package/dist/report/drill.js +78 -0
  14. package/dist/report/index.d.ts +4 -2
  15. package/dist/report/print.d.ts +23 -0
  16. package/dist/report/print.js +197 -0
  17. package/dist/report/render/aggregate.js +25 -1
  18. package/dist/report/types.d.ts +19 -1
  19. package/dist/types/report-designer.d.ts +13 -0
  20. package/dist/types/report-viewer.d.ts +19 -0
  21. package/package.json +6 -6
  22. package/src/components/report/__test__/report-designer.test.ts +98 -3
  23. package/src/components/report/__test__/report-drill.test.ts +692 -0
  24. package/src/components/report/__test__/report-viewer.test.ts +43 -0
  25. package/src/components/report/__test__/use-report-designer.test.ts +108 -1
  26. package/src/components/report/binding-badge.ts +5 -0
  27. package/src/components/report/designer/drill-dialog.vue +245 -0
  28. package/src/components/report/designer/float-panel.vue +12 -0
  29. package/src/components/report/print.ts +44 -0
  30. package/src/components/report/report-designer.vue +35 -2
  31. package/src/components/report/report-viewer.vue +171 -18
  32. package/src/components/report/style-designer.scss +66 -0
  33. package/src/components/report/style-viewer.scss +40 -0
  34. package/src/components/report/use-report-designer.ts +1 -0
  35. package/src/components/report/use-report-viewer.ts +150 -11
  36. package/src/report/__test__/drill.test.ts +194 -0
  37. package/src/report/__test__/print.test.ts +170 -0
  38. package/src/report/drill.ts +112 -0
  39. package/src/report/index.ts +4 -1
  40. package/src/report/print.ts +234 -0
  41. package/src/report/render/aggregate.ts +28 -0
  42. package/src/report/types.ts +20 -0
  43. package/src/types/report-designer.ts +18 -1
  44. package/src/types/report-viewer.ts +18 -1
@@ -0,0 +1,692 @@
1
+ import { Sheet, Workbook } from '@veltra/sheet-core'
2
+ import type { CellAddress } from '@veltra/sheet-core'
3
+ import { SheetGrid } from '@veltra/sheet-core/grid'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import { createApp, h, nextTick, type App } from 'vue'
6
+
7
+ import { UReportViewer } from '../../../index'
8
+ import { REPORT_META_NAMESPACE } from '../../../report/binding'
9
+ import type { DataConnection, DataConnector } from '../../../report/connector'
10
+ import { createReportTemplate } from '../../../report/template'
11
+ import type { ReportDatasetDef, ReportTemplate } from '../../../report/template'
12
+ import type { ParamValues, ReportBinding } from '../../../report/types'
13
+ import type { ReportViewerExposed, SheetExposed } from '../../../types'
14
+
15
+ // ---- 内联 fixtures:stub connector(按 SQL 路由行记录)+ 三级下钻模板 ----
16
+
17
+ const MYSQL: DataConnection = {
18
+ id: 'c1',
19
+ label: 'stub MySQL',
20
+ type: 'mysql',
21
+ host: '127.0.0.1',
22
+ port: 3306,
23
+ database: 'demo',
24
+ username: 'root',
25
+ password: ''
26
+ }
27
+
28
+ const SUMMARY_SQL = 'SELECT region, total FROM summary WHERE region LIKE ${keyword}'
29
+ const ORDERS_SQL = 'SELECT customer, amount FROM orders WHERE region LIKE ${p_region}'
30
+ const ITEMS_SQL = 'SELECT item, qty FROM items WHERE customer LIKE ${p_customer}'
31
+ const SELF_SQL = 'SELECT region FROM t WHERE region LIKE ${p_region}'
32
+
33
+ const SUMMARY_DATASET: ReportDatasetDef = {
34
+ id: 'summary',
35
+ label: '地区汇总',
36
+ connection: MYSQL,
37
+ sql: SUMMARY_SQL
38
+ }
39
+ const ORDERS_DATASET: ReportDatasetDef = {
40
+ id: 'orders',
41
+ label: '销售明细',
42
+ connection: MYSQL,
43
+ sql: ORDERS_SQL
44
+ }
45
+ const ITEMS_DATASET: ReportDatasetDef = {
46
+ id: 'items',
47
+ label: '项目明细',
48
+ connection: MYSQL,
49
+ sql: ITEMS_SQL
50
+ }
51
+ const SELF_DATASET: ReportDatasetDef = {
52
+ id: 'self',
53
+ label: '自指报表',
54
+ connection: MYSQL,
55
+ sql: SELF_SQL
56
+ }
57
+
58
+ const SUMMARY_ROWS: Record<string, unknown>[] = [
59
+ { region: '华东', total: 300 },
60
+ { region: '华南', total: 100 }
61
+ ]
62
+ const ORDER_ROWS: Record<string, unknown>[] = [
63
+ { customer: '甲公司', amount: 100 },
64
+ { customer: '乙公司', amount: 200 }
65
+ ]
66
+ const ITEM_ROWS: Record<string, unknown>[] = [{ item: 'X 项目', qty: 1 }]
67
+
68
+ /** 主表:地区分组格配下钻(→ tpl-detail,region → p_region);总额汇总格不配下钻 */
69
+ function createSummaryTemplate(openMode: 'switch' | 'dialog' = 'switch'): ReportTemplate {
70
+ const sheet = new Sheet()
71
+ sheet.setCells([
72
+ { addr: { row: 0, col: 0 }, data: { v: '地区' } },
73
+ { addr: { row: 0, col: 1 }, data: { v: '总额' } }
74
+ ])
75
+ const group: ReportBinding = {
76
+ dataset: 'summary',
77
+ field: 'region',
78
+ aggregate: 'group',
79
+ expand: 'down',
80
+ drill: { target: 'tpl-detail', mapping: { region: 'p_region' }, openMode }
81
+ }
82
+ sheet.setCellMeta({ row: 1, col: 0 }, REPORT_META_NAMESPACE, group)
83
+ const total: ReportBinding = {
84
+ dataset: 'summary',
85
+ field: 'total',
86
+ aggregate: 'sum',
87
+ expand: 'none',
88
+ rowParent: { row: 1, col: 0 }
89
+ }
90
+ sheet.setCellMeta({ row: 1, col: 1 }, REPORT_META_NAMESPACE, total)
91
+ sheet.ensureTableSize(50, 10)
92
+ return createReportTemplate(sheet.snapshot(), [SUMMARY_DATASET])
93
+ }
94
+
95
+ /** 明细表:客户明细格配下钻(→ tpl-third,customer → p_customer) */
96
+ function createDetailTemplate(): ReportTemplate {
97
+ const sheet = new Sheet()
98
+ sheet.setCells([
99
+ { addr: { row: 0, col: 0 }, data: { v: '客户' } },
100
+ { addr: { row: 0, col: 1 }, data: { v: '金额' } }
101
+ ])
102
+ const customer: ReportBinding = {
103
+ dataset: 'orders',
104
+ field: 'customer',
105
+ aggregate: 'list',
106
+ expand: 'down',
107
+ drill: { target: 'tpl-third', mapping: { customer: 'p_customer' }, openMode: 'switch' }
108
+ }
109
+ sheet.setCellMeta({ row: 1, col: 0 }, REPORT_META_NAMESPACE, customer)
110
+ const amount: ReportBinding = {
111
+ dataset: 'orders',
112
+ field: 'amount',
113
+ aggregate: 'list',
114
+ expand: 'down'
115
+ }
116
+ sheet.setCellMeta({ row: 1, col: 1 }, REPORT_META_NAMESPACE, amount)
117
+ sheet.ensureTableSize(50, 10)
118
+ return createReportTemplate(sheet.snapshot(), [ORDERS_DATASET])
119
+ }
120
+
121
+ /** 第三级:无下钻的明细表 */
122
+ function createThirdTemplate(): ReportTemplate {
123
+ const sheet = new Sheet()
124
+ sheet.setCells([{ addr: { row: 0, col: 0 }, data: { v: '项目' } }])
125
+ const item: ReportBinding = { dataset: 'items', field: 'item', aggregate: 'list', expand: 'down' }
126
+ sheet.setCellMeta({ row: 1, col: 0 }, REPORT_META_NAMESPACE, item)
127
+ sheet.ensureTableSize(50, 10)
128
+ return createReportTemplate(sheet.snapshot(), [ITEMS_DATASET])
129
+ }
130
+
131
+ /** 自指表:地区分组格下钻指向自身(region → p_region) */
132
+ function createSelfTemplate(): ReportTemplate {
133
+ const sheet = new Sheet()
134
+ sheet.setCells([{ addr: { row: 0, col: 0 }, data: { v: '地区' } }])
135
+ const region: ReportBinding = {
136
+ dataset: 'self',
137
+ field: 'region',
138
+ aggregate: 'group',
139
+ expand: 'down',
140
+ drill: { target: 'tpl-self', mapping: { region: 'p_region' }, openMode: 'switch' }
141
+ }
142
+ sheet.setCellMeta({ row: 1, col: 0 }, REPORT_META_NAMESPACE, region)
143
+ sheet.ensureTableSize(50, 10)
144
+ return createReportTemplate(sheet.snapshot(), [SELF_DATASET])
145
+ }
146
+
147
+ interface QueryCall {
148
+ connection: DataConnection
149
+ sql: string
150
+ values: ParamValues
151
+ }
152
+
153
+ /** stub connector:记录 query 调用,按 SQL 路由到对应行记录 */
154
+ function createStubConnector(rowsBySql: Record<string, Record<string, unknown>[]>) {
155
+ const calls: QueryCall[] = []
156
+ const connector: DataConnector = {
157
+ test: () => Promise.resolve({ ok: true, data: undefined }),
158
+ describe: () => Promise.resolve({ ok: true, data: [] }),
159
+ query: (connection, sql, values) => {
160
+ calls.push({ connection, sql, values })
161
+ return Promise.resolve({ ok: true, data: { fields: [], rows: rowsBySql[sql] ?? [] } })
162
+ }
163
+ }
164
+ return { connector, calls }
165
+ }
166
+
167
+ /** stub 宿主契约:按 ref 从模板表解析(异步形态) */
168
+ function createTemplateResolver(map: Record<string, ReportTemplate>) {
169
+ return vi.fn((ref: string) => Promise.resolve(map[ref]))
170
+ }
171
+
172
+ // ---- 挂载与等待 ----
173
+
174
+ const apps: App[] = []
175
+ const containers: HTMLElement[] = []
176
+
177
+ function mountViewer(props: {
178
+ connector: DataConnector
179
+ template: ReportTemplate
180
+ workbook?: Workbook
181
+ resolveTemplate?: (ref: string) => Promise<ReportTemplate | undefined>
182
+ }) {
183
+ const el = document.createElement('div')
184
+ el.style.width = '800px'
185
+ el.style.height = '600px'
186
+ document.body.appendChild(el)
187
+ containers.push(el)
188
+ const exposedRef = { value: undefined as ReportViewerExposed | undefined }
189
+ const app = createApp({
190
+ render: () =>
191
+ h(UReportViewer, {
192
+ ...props,
193
+ ref: (value: unknown) => {
194
+ exposedRef.value = value as ReportViewerExposed | undefined
195
+ }
196
+ })
197
+ })
198
+ app.mount(el)
199
+ apps.push(app)
200
+ return { app, el, exposedRef }
201
+ }
202
+
203
+ /** 内嵌 USheet 的活动 sheet(弹框内查看器未注入 workbook 时用) */
204
+ function nestedActiveSheet(el: HTMLElement) {
205
+ const sheetEl = el.querySelector('.u-sheet') as
206
+ | (HTMLElement & { __vueParentComponent?: { exposed?: SheetExposed } })
207
+ | null
208
+ return sheetEl?.__vueParentComponent?.exposed?.getActiveSheet()
209
+ }
210
+
211
+ /** 下钻弹框(UDialog Teleport 到 body;class 打在对话框根上) */
212
+ function drillDialogEl(): HTMLElement | null {
213
+ return document.querySelector('.u-report-viewer__drill-dialog')
214
+ }
215
+
216
+ async function openDrillDialog(el: HTMLElement): Promise<HTMLElement> {
217
+ mockHitAddr({ row: 1, col: 0 })
218
+ dispatchGridClick(el)
219
+ await flushViewer()
220
+ await flushViewer()
221
+ const dialog = drillDialogEl()
222
+ expect(dialog).toBeTruthy()
223
+ return dialog!
224
+ }
225
+
226
+ /** 等待取数 / 下钻解析(宏/微任务)+ 填充快照应用(watch + nextTick)落定 */
227
+ async function flushViewer(): Promise<void> {
228
+ await nextTick()
229
+ await new Promise((resolve) => setTimeout(resolve, 0))
230
+ await nextTick()
231
+ await nextTick()
232
+ }
233
+
234
+ /** 网格命中测试 mock:网格随内容尺寸重建,按原型 mock 一次,逐次改返回值 */
235
+ let hitSpy: ReturnType<typeof vi.spyOn> | null = null
236
+
237
+ function mockHitAddr(addr: CellAddress | null): void {
238
+ if (!hitSpy) {
239
+ hitSpy = vi.spyOn(SheetGrid.prototype, 'hitTestSheetAddr')
240
+ }
241
+ hitSpy.mockReturnValue(addr)
242
+ }
243
+
244
+ /** 在网格上模拟一次单击(pointerdown + click 同坐标) */
245
+ function dispatchGridClick(el: HTMLElement, x = 10, y = 10): void {
246
+ const gridEl = el.querySelector('.u-sheet__grid-instance')!
247
+ gridEl.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y }))
248
+ gridEl.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: x, clientY: y }))
249
+ }
250
+
251
+ function dispatchGridPointerMove(el: HTMLElement, x = 10, y = 10): void {
252
+ const gridEl = el.querySelector('.u-sheet__grid-instance')!
253
+ gridEl.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: x, clientY: y }))
254
+ }
255
+
256
+ async function clickButton(button: HTMLElement): Promise<void> {
257
+ button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
258
+ await flushViewer()
259
+ }
260
+
261
+ afterEach(() => {
262
+ hitSpy?.mockRestore()
263
+ hitSpy = null
264
+ while (apps.length) apps.pop()!.unmount()
265
+ while (containers.length) containers.pop()!.remove()
266
+ document.querySelectorAll('.u-dialog__overlay').forEach((node) => node.remove())
267
+ })
268
+
269
+ const SHEET_CLS = 'u-report-viewer__sheet'
270
+ const DRILL_HOVER_CLS = 'u-report-viewer__sheet--drill-hover'
271
+ const DRILL_BAR_CLS = '.u-report-viewer__drill-bar'
272
+
273
+ describe('UReportViewer 下钻(查看器内切换)', () => {
274
+ it('单击配了下钻的分组格:解析目标模板、映射参数带入 Filter Bar 并取数渲染', async () => {
275
+ const { connector, calls } = createStubConnector({
276
+ [SUMMARY_SQL]: SUMMARY_ROWS,
277
+ [ORDERS_SQL]: ORDER_ROWS
278
+ })
279
+ const resolveTemplate = createTemplateResolver({ 'tpl-detail': createDetailTemplate() })
280
+ const workbook = new Workbook()
281
+ const { el } = mountViewer({
282
+ connector,
283
+ template: createSummaryTemplate(),
284
+ workbook,
285
+ resolveTemplate
286
+ })
287
+ await flushViewer()
288
+ expect(calls).toHaveLength(1)
289
+ expect(calls[0]).toMatchObject({ sql: SUMMARY_SQL, values: { keyword: '' } })
290
+
291
+ // 单击 (1,0) 华东分组格
292
+ mockHitAddr({ row: 1, col: 0 })
293
+ dispatchGridClick(el)
294
+ await flushViewer()
295
+
296
+ // 宿主契约按 target 引用解析
297
+ expect(resolveTemplate).toHaveBeenCalledWith('tpl-detail')
298
+ // 映射参数带入详情报取数
299
+ expect(calls).toHaveLength(2)
300
+ expect(calls[1]).toMatchObject({ sql: ORDERS_SQL, values: { p_region: '华东' } })
301
+ // Filter Bar 自动填入映射值
302
+ const input = el.querySelector<HTMLInputElement>('.u-report-filter-bar .u-input input')!
303
+ expect(input.value).toBe('华东')
304
+ // 详情报渲染:当前可见层替换为明细数据
305
+ expect(workbook.activeSheet.getDisplayValue({ row: 1, col: 0 })).toBe('甲公司')
306
+ // 栈深 1:出现回退入口
307
+ expect(el.querySelector(DRILL_BAR_CLS)).toBeTruthy()
308
+ })
309
+
310
+ it('未配置下钻的格子(静态格 / 无 drill 绑定格)单击不跳转', async () => {
311
+ const { connector, calls } = createStubConnector({ [SUMMARY_SQL]: SUMMARY_ROWS })
312
+ const resolveTemplate = createTemplateResolver({ 'tpl-detail': createDetailTemplate() })
313
+ const { el } = mountViewer({
314
+ connector,
315
+ template: createSummaryTemplate(),
316
+ workbook: new Workbook(),
317
+ resolveTemplate
318
+ })
319
+ await flushViewer()
320
+ expect(calls).toHaveLength(1)
321
+
322
+ // 静态表头格
323
+ mockHitAddr({ row: 0, col: 0 })
324
+ dispatchGridClick(el)
325
+ await flushViewer()
326
+ // 无 drill 的汇总绑定格
327
+ mockHitAddr({ row: 1, col: 1 })
328
+ dispatchGridClick(el)
329
+ await flushViewer()
330
+
331
+ expect(resolveTemplate).not.toHaveBeenCalled()
332
+ expect(calls).toHaveLength(1)
333
+ expect(el.querySelector(DRILL_BAR_CLS)).toBeNull()
334
+ })
335
+
336
+ it('拖拽框选(位移超阈值)不触发下钻,同点位单击触发', async () => {
337
+ const { connector } = createStubConnector({
338
+ [SUMMARY_SQL]: SUMMARY_ROWS,
339
+ [ORDERS_SQL]: ORDER_ROWS
340
+ })
341
+ const resolveTemplate = createTemplateResolver({ 'tpl-detail': createDetailTemplate() })
342
+ const { el } = mountViewer({
343
+ connector,
344
+ template: createSummaryTemplate(),
345
+ workbook: new Workbook(),
346
+ resolveTemplate
347
+ })
348
+ await flushViewer()
349
+
350
+ const gridEl = el.querySelector('.u-sheet__grid-instance')!
351
+ mockHitAddr({ row: 1, col: 0 })
352
+ // 拖拽框选:pointerdown 与 click 位移 80px
353
+ gridEl.dispatchEvent(
354
+ new PointerEvent('pointerdown', { bubbles: true, clientX: 10, clientY: 10 })
355
+ )
356
+ gridEl.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 90, clientY: 10 }))
357
+ await flushViewer()
358
+ expect(resolveTemplate).not.toHaveBeenCalled()
359
+
360
+ // 同点位单击:正常触发
361
+ dispatchGridClick(el)
362
+ await flushViewer()
363
+ expect(resolveTemplate).toHaveBeenCalledWith('tpl-detail')
364
+ })
365
+
366
+ it('多层下钻逐级回退:回退后该层 Filter Bar 为当时值并重新取数渲染', async () => {
367
+ const { connector, calls } = createStubConnector({
368
+ [SUMMARY_SQL]: SUMMARY_ROWS,
369
+ [ORDERS_SQL]: ORDER_ROWS,
370
+ [ITEMS_SQL]: ITEM_ROWS
371
+ })
372
+ const resolveTemplate = createTemplateResolver({
373
+ 'tpl-detail': createDetailTemplate(),
374
+ 'tpl-third': createThirdTemplate()
375
+ })
376
+ const workbook = new Workbook()
377
+ const { el } = mountViewer({
378
+ connector,
379
+ template: createSummaryTemplate(),
380
+ workbook,
381
+ resolveTemplate
382
+ })
383
+ await flushViewer()
384
+
385
+ // 主表 Filter Bar 改值为「华东」(离开时的当时值)
386
+ const rootInput = el.querySelector<HTMLInputElement>('.u-report-filter-bar .u-input input')!
387
+ rootInput.value = '华东'
388
+ rootInput.dispatchEvent(
389
+ new InputEvent('input', { bubbles: true, data: '华东', inputType: 'insertText' })
390
+ )
391
+ await flushViewer()
392
+ expect(calls).toHaveLength(2)
393
+ expect(calls[1]).toMatchObject({ sql: SUMMARY_SQL, values: { keyword: '华东' } })
394
+
395
+ // 第一层:主表 → 明细表(region=华东 → p_region)
396
+ mockHitAddr({ row: 1, col: 0 })
397
+ dispatchGridClick(el)
398
+ await flushViewer()
399
+ expect(calls[2]).toMatchObject({ sql: ORDERS_SQL, values: { p_region: '华东' } })
400
+
401
+ // 第二层:明细表 → 项目表(customer=甲公司 → p_customer)
402
+ mockHitAddr({ row: 1, col: 0 })
403
+ dispatchGridClick(el)
404
+ await flushViewer()
405
+ expect(calls[3]).toMatchObject({ sql: ITEMS_SQL, values: { p_customer: '甲公司' } })
406
+ expect(workbook.activeSheet.getDisplayValue({ row: 1, col: 0 })).toBe('X 项目')
407
+
408
+ // 回退到明细表:当时值 p_region=华东,重新取数渲染
409
+ await clickButton(el.querySelector<HTMLElement>(`${DRILL_BAR_CLS} button`)!)
410
+ expect(calls[4]).toMatchObject({ sql: ORDERS_SQL, values: { p_region: '华东' } })
411
+ expect(el.querySelector<HTMLInputElement>('.u-report-filter-bar .u-input input')!.value).toBe(
412
+ '华东'
413
+ )
414
+ expect(workbook.activeSheet.getDisplayValue({ row: 1, col: 0 })).toBe('甲公司')
415
+ expect(el.querySelector(DRILL_BAR_CLS)).toBeTruthy()
416
+
417
+ // 回退到主表:当时值 keyword=华东(离开前的改值),重新取数渲染
418
+ await clickButton(el.querySelector<HTMLElement>(`${DRILL_BAR_CLS} button`)!)
419
+ expect(calls[5]).toMatchObject({ sql: SUMMARY_SQL, values: { keyword: '华东' } })
420
+ expect(el.querySelector<HTMLInputElement>('.u-report-filter-bar .u-input input')!.value).toBe(
421
+ '华东'
422
+ )
423
+ expect(workbook.activeSheet.getDisplayValue({ row: 1, col: 0 })).toBe('华东')
424
+ expect(el.querySelector(DRILL_BAR_CLS)).toBeNull()
425
+ })
426
+
427
+ it('resolveTemplate 抛错:可读错误提示,停留当前报表,下钻栈不变', async () => {
428
+ const { connector, calls } = createStubConnector({ [SUMMARY_SQL]: SUMMARY_ROWS })
429
+ const resolveTemplate = vi.fn(() => Promise.reject(new Error('模板服务不可用')))
430
+ const workbook = new Workbook()
431
+ const { el } = mountViewer({
432
+ connector,
433
+ template: createSummaryTemplate(),
434
+ workbook,
435
+ resolveTemplate
436
+ })
437
+ await flushViewer()
438
+ expect(calls).toHaveLength(1)
439
+
440
+ mockHitAddr({ row: 1, col: 0 })
441
+ dispatchGridClick(el)
442
+ await flushViewer()
443
+
444
+ const errorBanner = el.querySelector('.u-report-viewer__error')!
445
+ expect(errorBanner.textContent).toContain('模板服务不可用')
446
+ // 停留当前报:填充内容不变,不再取数,无回退入口
447
+ expect(workbook.activeSheet.getDisplayValue({ row: 1, col: 0 })).toBe('华东')
448
+ expect(calls).toHaveLength(1)
449
+ expect(el.querySelector(DRILL_BAR_CLS)).toBeNull()
450
+ })
451
+
452
+ it('resolveTemplate 返回空结果:可读错误提示,停留当前报表', async () => {
453
+ const { connector, calls } = createStubConnector({ [SUMMARY_SQL]: SUMMARY_ROWS })
454
+ const resolveTemplate = createTemplateResolver({})
455
+ const workbook = new Workbook()
456
+ const { el } = mountViewer({
457
+ connector,
458
+ template: createSummaryTemplate(),
459
+ workbook,
460
+ resolveTemplate
461
+ })
462
+ await flushViewer()
463
+
464
+ mockHitAddr({ row: 1, col: 0 })
465
+ dispatchGridClick(el)
466
+ await flushViewer()
467
+
468
+ expect(resolveTemplate).toHaveBeenCalledWith('tpl-detail')
469
+ const errorBanner = el.querySelector('.u-report-viewer__error')!
470
+ expect(errorBanner.textContent).toContain('tpl-detail')
471
+ expect(workbook.activeSheet.getDisplayValue({ row: 1, col: 0 })).toBe('华东')
472
+ expect(calls).toHaveLength(1)
473
+ expect(el.querySelector(DRILL_BAR_CLS)).toBeNull()
474
+ })
475
+
476
+ it('下钻指向自身:正常压栈并逐级回退', async () => {
477
+ const { connector, calls } = createStubConnector({ [SELF_SQL]: SUMMARY_ROWS })
478
+ const selfTemplate = createSelfTemplate()
479
+ const resolveTemplate = createTemplateResolver({ 'tpl-self': selfTemplate })
480
+ const { el } = mountViewer({
481
+ connector,
482
+ template: selfTemplate,
483
+ workbook: new Workbook(),
484
+ resolveTemplate
485
+ })
486
+ await flushViewer()
487
+ expect(calls[0]).toMatchObject({ sql: SELF_SQL, values: { p_region: '' } })
488
+
489
+ // 第一次自指:(1,0) 华东 → p_region=华东
490
+ mockHitAddr({ row: 1, col: 0 })
491
+ dispatchGridClick(el)
492
+ await flushViewer()
493
+ expect(calls[1]).toMatchObject({ sql: SELF_SQL, values: { p_region: '华东' } })
494
+
495
+ // 第二次自指:(2,0) 华南 → p_region=华南
496
+ mockHitAddr({ row: 2, col: 0 })
497
+ dispatchGridClick(el)
498
+ await flushViewer()
499
+ expect(calls[2]).toMatchObject({ sql: SELF_SQL, values: { p_region: '华南' } })
500
+
501
+ // 逐级回退:恢复各层当时参数并重新取数
502
+ await clickButton(el.querySelector<HTMLElement>(`${DRILL_BAR_CLS} button`)!)
503
+ expect(calls[3]).toMatchObject({ sql: SELF_SQL, values: { p_region: '华东' } })
504
+ await clickButton(el.querySelector<HTMLElement>(`${DRILL_BAR_CLS} button`)!)
505
+ expect(calls[4]).toMatchObject({ sql: SELF_SQL, values: { p_region: '' } })
506
+ expect(el.querySelector(DRILL_BAR_CLS)).toBeNull()
507
+ })
508
+
509
+ it('悬停配了下钻的格子时出现可点光标提示,移出后消失', async () => {
510
+ const { connector } = createStubConnector({ [SUMMARY_SQL]: SUMMARY_ROWS })
511
+ const resolveTemplate = createTemplateResolver({ 'tpl-detail': createDetailTemplate() })
512
+ const { el } = mountViewer({
513
+ connector,
514
+ template: createSummaryTemplate(),
515
+ workbook: new Workbook(),
516
+ resolveTemplate
517
+ })
518
+ await flushViewer()
519
+
520
+ const sheetEl = el.querySelector(`.${SHEET_CLS}`)!
521
+ mockHitAddr({ row: 1, col: 0 })
522
+ dispatchGridPointerMove(el)
523
+ await nextTick()
524
+ expect(sheetEl.classList.contains(DRILL_HOVER_CLS)).toBe(true)
525
+
526
+ mockHitAddr({ row: 0, col: 0 })
527
+ dispatchGridPointerMove(el)
528
+ await nextTick()
529
+ expect(sheetEl.classList.contains(DRILL_HOVER_CLS)).toBe(false)
530
+ })
531
+
532
+ it('查看态自动为配了下钻的单元格应用超链接样式(蓝色字体与下划线)', async () => {
533
+ const { connector } = createStubConnector({ [SUMMARY_SQL]: SUMMARY_ROWS })
534
+ const resolveTemplate = createTemplateResolver({ 'tpl-detail': createDetailTemplate() })
535
+ const { el } = mountViewer({
536
+ connector,
537
+ template: createSummaryTemplate(),
538
+ workbook: new Workbook(),
539
+ resolveTemplate
540
+ })
541
+ await flushViewer()
542
+
543
+ const sheetComp = (el.querySelector('.u-sheet') as any)?.__vueParentComponent
544
+ const sheetProps = sheetComp?.props
545
+ expect(typeof sheetProps?.resolveCellStyle).toBe('function')
546
+
547
+ // (1,0) 华东分组格配了下钻 -> 带有超链接样式
548
+ const drillStyle = sheetProps.resolveCellStyle({ row: 1, col: 0 }, undefined)
549
+ expect(drillStyle?.font?.color).toBe('#2f54eb')
550
+ expect(drillStyle?.font?.underline).toBe(true)
551
+
552
+ // (0,0) 静态表头未配下钻 -> 不附加超链接样式
553
+ const staticStyle = sheetProps.resolveCellStyle({ row: 0, col: 0 }, undefined)
554
+ expect(staticStyle).toBeUndefined()
555
+ })
556
+ })
557
+
558
+ describe('UReportViewer 下钻(UDialog 弹框)', () => {
559
+ it('dialog 方式打开完整查看器:映射参数带入、Filter Bar 可改值,外层不受影响', async () => {
560
+ const { connector, calls } = createStubConnector({
561
+ [SUMMARY_SQL]: SUMMARY_ROWS,
562
+ [ORDERS_SQL]: ORDER_ROWS
563
+ })
564
+ const resolveTemplate = createTemplateResolver({ 'tpl-detail': createDetailTemplate() })
565
+ const workbook = new Workbook()
566
+ const { el } = mountViewer({
567
+ connector,
568
+ template: createSummaryTemplate('dialog'),
569
+ workbook,
570
+ resolveTemplate
571
+ })
572
+ await flushViewer()
573
+ expect(calls).toHaveLength(1)
574
+
575
+ const dialog = await openDrillDialog(el)
576
+ expect(resolveTemplate).toHaveBeenCalledWith('tpl-detail')
577
+ expect(calls).toHaveLength(2)
578
+ expect(calls[1]).toMatchObject({ sql: ORDERS_SQL, values: { p_region: '华东' } })
579
+
580
+ const innerInput = dialog.querySelector<HTMLInputElement>(
581
+ '.u-report-filter-bar .u-input input'
582
+ )!
583
+ expect(innerInput.value).toBe('华东')
584
+ expect(nestedActiveSheet(dialog)?.getDisplayValue({ row: 1, col: 0 })).toBe('甲公司')
585
+
586
+ // 外层仍是主表:无回退条、填充不变
587
+ expect(el.querySelector(DRILL_BAR_CLS)).toBeNull()
588
+ expect(workbook.activeSheet.getDisplayValue({ row: 1, col: 0 })).toBe('华东')
589
+
590
+ innerInput.value = '华南'
591
+ innerInput.dispatchEvent(
592
+ new InputEvent('input', { bubbles: true, data: '华南', inputType: 'insertText' })
593
+ )
594
+ await flushViewer()
595
+ expect(calls[2]).toMatchObject({ sql: ORDERS_SQL, values: { p_region: '华南' } })
596
+ expect(workbook.activeSheet.getDisplayValue({ row: 1, col: 0 })).toBe('华东')
597
+ })
598
+
599
+ it('弹框内可继续下钻与逐级回退,外层栈不变', async () => {
600
+ const { connector, calls } = createStubConnector({
601
+ [SUMMARY_SQL]: SUMMARY_ROWS,
602
+ [ORDERS_SQL]: ORDER_ROWS,
603
+ [ITEMS_SQL]: ITEM_ROWS
604
+ })
605
+ const resolveTemplate = createTemplateResolver({
606
+ 'tpl-detail': createDetailTemplate(),
607
+ 'tpl-third': createThirdTemplate()
608
+ })
609
+ const workbook = new Workbook()
610
+ const { el } = mountViewer({
611
+ connector,
612
+ template: createSummaryTemplate('dialog'),
613
+ workbook,
614
+ resolveTemplate
615
+ })
616
+ await flushViewer()
617
+
618
+ const dialog = await openDrillDialog(el)
619
+ mockHitAddr({ row: 1, col: 0 })
620
+ dispatchGridClick(dialog)
621
+ await flushViewer()
622
+
623
+ expect(calls[2]).toMatchObject({ sql: ITEMS_SQL, values: { p_customer: '甲公司' } })
624
+ expect(nestedActiveSheet(dialog)?.getDisplayValue({ row: 1, col: 0 })).toBe('X 项目')
625
+ expect(dialog.querySelector(DRILL_BAR_CLS)).toBeTruthy()
626
+ expect(el.querySelector(DRILL_BAR_CLS)).toBeNull()
627
+ expect(workbook.activeSheet.getDisplayValue({ row: 1, col: 0 })).toBe('华东')
628
+
629
+ await clickButton(dialog.querySelector<HTMLElement>(`${DRILL_BAR_CLS} button`)!)
630
+ expect(calls[3]).toMatchObject({ sql: ORDERS_SQL, values: { p_region: '华东' } })
631
+ expect(nestedActiveSheet(dialog)?.getDisplayValue({ row: 1, col: 0 })).toBe('甲公司')
632
+ expect(dialog.querySelector(DRILL_BAR_CLS)).toBeNull()
633
+ expect(el.querySelector(DRILL_BAR_CLS)).toBeNull()
634
+ })
635
+
636
+ it('关闭弹框(按钮 / 遮罩 / Esc)丢弃框内栈,回到打开前', async () => {
637
+ const { connector, calls } = createStubConnector({
638
+ [SUMMARY_SQL]: SUMMARY_ROWS,
639
+ [ORDERS_SQL]: ORDER_ROWS,
640
+ [ITEMS_SQL]: ITEM_ROWS
641
+ })
642
+ const resolveTemplate = createTemplateResolver({
643
+ 'tpl-detail': createDetailTemplate(),
644
+ 'tpl-third': createThirdTemplate()
645
+ })
646
+ const workbook = new Workbook()
647
+ const { el } = mountViewer({
648
+ connector,
649
+ template: createSummaryTemplate('dialog'),
650
+ workbook,
651
+ resolveTemplate
652
+ })
653
+ await flushViewer()
654
+
655
+ async function drillInside(dialog: HTMLElement): Promise<void> {
656
+ mockHitAddr({ row: 1, col: 0 })
657
+ dispatchGridClick(dialog)
658
+ await flushViewer()
659
+ expect(nestedActiveSheet(dialog)?.getDisplayValue({ row: 1, col: 0 })).toBe('X 项目')
660
+ }
661
+
662
+ function assertOuterUnchanged(): void {
663
+ expect(drillDialogEl()).toBeNull()
664
+ expect(el.querySelector(DRILL_BAR_CLS)).toBeNull()
665
+ expect(workbook.activeSheet.getDisplayValue({ row: 1, col: 0 })).toBe('华东')
666
+ }
667
+
668
+ const byButton = await openDrillDialog(el)
669
+ await drillInside(byButton)
670
+ byButton.querySelector<HTMLElement>('.u-dialog__btn-close')!.click()
671
+ await flushViewer()
672
+ assertOuterUnchanged()
673
+
674
+ // 再打开应是新的框内栈(明细层),而不是关闭前的第三层
675
+ const again = await openDrillDialog(el)
676
+ expect(nestedActiveSheet(again)?.getDisplayValue({ row: 1, col: 0 })).toBe('甲公司')
677
+ expect(again.querySelector(DRILL_BAR_CLS)).toBeNull()
678
+ await drillInside(again)
679
+ again.parentElement!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
680
+ await flushViewer()
681
+ assertOuterUnchanged()
682
+
683
+ const byEsc = await openDrillDialog(el)
684
+ await drillInside(byEsc)
685
+ byEsc.parentElement!.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true }))
686
+ await flushViewer()
687
+ assertOuterUnchanged()
688
+
689
+ // 关闭不触发外层重新取数(calls[0] 为主表,其余为弹框内)
690
+ expect(calls.filter((call) => call.sql === SUMMARY_SQL)).toHaveLength(1)
691
+ })
692
+ })