@veltra/sheet 2.3.3 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/components/report/binding-badge.js +23 -5
  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 +2 -1
  9. package/dist/components/report/use-report-viewer.js +88 -3
  10. package/dist/components/sheet/insert-image.js +1 -1
  11. package/dist/components/sheet/popups/import.worker.js +2 -3
  12. package/dist/components/sheet/use-sheet-state.js +2 -2
  13. package/dist/index.d.ts +5 -3
  14. package/dist/index.js +4 -2
  15. package/dist/report/drill.d.ts +48 -0
  16. package/dist/report/drill.js +78 -0
  17. package/dist/report/export-xlsx.d.ts +3 -7
  18. package/dist/report/export-xlsx.js +6 -85
  19. package/dist/report/index.d.ts +5 -3
  20. package/dist/report/print.d.ts +23 -0
  21. package/dist/report/print.js +197 -0
  22. package/dist/report/render/aggregate.js +25 -1
  23. package/dist/report/types.d.ts +19 -1
  24. package/dist/tools/download.js +2 -2
  25. package/dist/types/report-designer.d.ts +13 -0
  26. package/dist/types/report-viewer.d.ts +19 -0
  27. package/package.json +6 -9
  28. package/src/components/report/__test__/binding-badge.test.ts +92 -0
  29. package/src/components/report/__test__/report-designer.test.ts +98 -3
  30. package/src/components/report/__test__/report-drill.test.ts +692 -0
  31. package/src/components/report/__test__/report-viewer.test.ts +43 -0
  32. package/src/components/report/__test__/use-report-designer.test.ts +108 -1
  33. package/src/components/report/binding-badge.ts +35 -5
  34. package/src/components/report/designer/drill-dialog.vue +245 -0
  35. package/src/components/report/designer/float-panel.vue +12 -0
  36. package/src/components/report/print.ts +44 -0
  37. package/src/components/report/report-designer.vue +35 -2
  38. package/src/components/report/report-viewer.vue +171 -18
  39. package/src/components/report/style-designer.scss +66 -0
  40. package/src/components/report/style-viewer.scss +40 -0
  41. package/src/components/report/use-report-designer.ts +8 -1
  42. package/src/components/report/use-report-viewer.ts +150 -11
  43. package/src/components/sheet/insert-image.ts +1 -1
  44. package/src/components/sheet/popups/export-popup.vue +1 -1
  45. package/src/components/sheet/popups/import.worker.ts +7 -10
  46. package/src/components/sheet/use-sheet-state.ts +2 -2
  47. package/src/report/__test__/drill.test.ts +194 -0
  48. package/src/report/__test__/export-xlsx.test.ts +3 -25
  49. package/src/report/__test__/print.test.ts +170 -0
  50. package/src/report/__test__/render.test.ts +26 -0
  51. package/src/report/drill.ts +112 -0
  52. package/src/report/export-xlsx.ts +5 -103
  53. package/src/report/index.ts +4 -1
  54. package/src/report/print.ts +234 -0
  55. package/src/report/render/aggregate.ts +28 -0
  56. package/src/report/types.ts +20 -0
  57. package/src/tools/__test__/builtin.test.ts +1 -1
  58. package/src/tools/download.ts +2 -2
  59. package/src/tools/export.worker.ts +2 -2
  60. package/src/types/report-designer.ts +18 -1
  61. package/src/types/report-viewer.ts +18 -1
@@ -354,4 +354,47 @@ describe('UReportViewer', () => {
354
354
  exportSpy.mockRestore()
355
355
  saveBlobSpy.mockRestore()
356
356
  })
357
+
358
+ it('取数前 print() 拒绝;取数失败或未就绪时同样拒绝', async () => {
359
+ let gate = deferred<QueryOutcome>()
360
+ const outcome: QueryOutcome = {
361
+ error: { code: 'CONNECTION_FAILED', message: '数据库连接失败' }
362
+ }
363
+ const { connector } = createStubConnector(() => gate.promise)
364
+ const { exposedRef } = mountViewer({
365
+ connector,
366
+ template: createViewerTemplate(),
367
+ workbook: new Workbook()
368
+ })
369
+ await nextTick()
370
+
371
+ expect(() => exposedRef.value!.print()).toThrow('报表数据加载中')
372
+
373
+ gate.resolve(outcome)
374
+ await flushViewer()
375
+ expect(() => exposedRef.value!.print()).toThrow('报表数据尚未就绪')
376
+ })
377
+
378
+ it('取数后 print() 生成打印 HTML 并送隐藏 iframe 打印', async () => {
379
+ const { connector } = createStubConnector({ rows: ORDER_ROWS })
380
+ const workbook = new Workbook()
381
+ const { exposedRef } = mountViewer({ connector, template: createViewerTemplate(), workbook })
382
+ await flushViewer()
383
+
384
+ const printModule = await import('../../../report/print')
385
+ const domPrintModule = await import('../print')
386
+ const buildSpy = vi.spyOn(printModule, 'buildFilledReportPrintHtml')
387
+ const printSpy = vi.spyOn(domPrintModule, 'printHtmlDocument').mockImplementation(() => {})
388
+
389
+ exposedRef.value!.print({ orientation: 'landscape' })
390
+
391
+ expect(buildSpy).toHaveBeenCalledWith(workbook.activeSheet, { orientation: 'landscape' })
392
+ const html = buildSpy.mock.results[0]!.value as string
393
+ expect(html).toContain('甲公司')
394
+ expect(html).toContain('@page { size: A4 landscape;')
395
+ expect(printSpy).toHaveBeenCalledWith(html)
396
+
397
+ buildSpy.mockRestore()
398
+ printSpy.mockRestore()
399
+ })
357
400
  })
@@ -5,7 +5,12 @@ import { effectScope, ref, type EffectScope, type Ref } from 'vue'
5
5
  import { REPORT_META_NAMESPACE } from '../../../report/binding'
6
6
  import type { DataConnection, DataConnector } from '../../../report/connector'
7
7
  import { IncompatibleTemplateVersionError, type ReportTemplate } from '../../../report/template'
8
- import type { DatasetField, ParamValues, ReportBinding } from '../../../report/types'
8
+ import type {
9
+ DatasetField,
10
+ ParamValues,
11
+ ReportBinding,
12
+ ReportDrillConfig
13
+ } from '../../../report/types'
9
14
  import { roleBindingDefaults } from '../designer/role'
10
15
  import { useReportDesigner, type UseReportDesignerReturn } from '../use-report-designer'
11
16
 
@@ -559,6 +564,108 @@ describe('useReportDesigner:Action Pill 就地编辑(角色 / 聚合 / 条
559
564
  })
560
565
  })
561
566
 
567
+ describe('useReportDesigner:下钻配置读写与序列化', () => {
568
+ const DRILL: ReportDrillConfig = {
569
+ target: 'tpl-order-detail',
570
+ mapping: { customer: 'p_customer', amount: 'p_minAmount' },
571
+ openMode: 'dialog'
572
+ }
573
+
574
+ async function seedAndBind(
575
+ designer: UseReportDesignerReturn,
576
+ workbook: Workbook,
577
+ addr: { row: number; col: number }
578
+ ) {
579
+ const dataset = designer.addDataset('c1')
580
+ designer.updateDataset(dataset.id, { sql: 'SELECT customer, amount FROM orders' })
581
+ await designer.describeDataset(dataset.id)
582
+ designer.bindField(dataset.id, 'customer', addr)
583
+ workbook.activeSheet.selectCell(addr)
584
+ return dataset
585
+ }
586
+
587
+ it('drill 经 patchActiveBinding 写入,随 getTemplate() 序列化 round-trip', async () => {
588
+ const { designer, workbook } = createDesigner()
589
+ await seedAndBind(designer, workbook, { row: 0, col: 0 })
590
+
591
+ designer.patchActiveBinding({ drill: DRILL })
592
+ expect(designer.activeBinding.value?.drill).toEqual(DRILL)
593
+
594
+ const template = designer.getTemplate()
595
+ expect(template.version).toBe(1)
596
+ const meta = template.meta ?? []
597
+ expect(meta).toHaveLength(1)
598
+ expect((meta[0]!.payload as ReportBinding).drill).toEqual(DRILL)
599
+
600
+ // JSON 序列化往返后下钻配置完整(模板可序列化流转)
601
+ const restored = JSON.parse(JSON.stringify(template)) as ReportTemplate
602
+ expect((restored.meta![0]!.payload as ReportBinding).drill).toEqual(DRILL)
603
+ })
604
+
605
+ it('patchActiveBinding({ drill: undefined }) 移除下钻配置(meta 不留 drill 键)', async () => {
606
+ const { designer, workbook } = createDesigner()
607
+ await seedAndBind(designer, workbook, { row: 0, col: 0 })
608
+
609
+ designer.patchActiveBinding({ drill: DRILL })
610
+ designer.patchActiveBinding({ drill: undefined })
611
+
612
+ const binding = workbook.activeSheet.getCellMeta<ReportBinding>(
613
+ { row: 0, col: 0 },
614
+ REPORT_META_NAMESPACE
615
+ )
616
+ expect(binding).toBeTruthy()
617
+ expect('drill' in binding!).toBe(false)
618
+ expect(designer.getTemplate().meta![0]!.payload).not.toHaveProperty('drill')
619
+ })
620
+
621
+ it('载入含下钻配置的模板后还原,getTemplate 往返保留', async () => {
622
+ const binding: ReportBinding = {
623
+ dataset: 'ds-template',
624
+ field: 'customer',
625
+ preset: 'groupHeader',
626
+ aggregate: 'group',
627
+ expand: 'down',
628
+ drill: DRILL
629
+ }
630
+ const template: ReportTemplate = {
631
+ version: 1,
632
+ cells: [],
633
+ styles: [],
634
+ merges: [],
635
+ frozen: { rows: 0, cols: 0 },
636
+ rows: 24,
637
+ cols: 10,
638
+ meta: [{ row: 1, col: 0, namespace: REPORT_META_NAMESPACE, payload: binding }],
639
+ datasets: [
640
+ {
641
+ id: 'ds-template',
642
+ label: '模板数据集',
643
+ connection: { ...PG, id: 'conn-template' },
644
+ sql: 'SELECT customer, amount FROM orders'
645
+ }
646
+ ]
647
+ }
648
+
649
+ const { designer, workbook } = createDesigner([MYSQL], { template })
650
+
651
+ // 下钻配置随绑定 meta 还原
652
+ const loaded = workbook.activeSheet.getCellMeta<ReportBinding>(
653
+ { row: 1, col: 0 },
654
+ REPORT_META_NAMESPACE
655
+ )
656
+ expect(loaded?.drill).toEqual(DRILL)
657
+
658
+ // 选中该格:activeBinding 暴露 drill(Action Pill 下钻入口回填数据源)
659
+ workbook.activeSheet.selectCell({ row: 1, col: 0 })
660
+ expect(designer.activeBinding.value?.drill).toEqual(DRILL)
661
+
662
+ // 往返吐出保留
663
+ const roundTrip = designer.getTemplate()
664
+ expect(roundTrip.version).toBe(1)
665
+ expect((roundTrip.meta![0]!.payload as ReportBinding).drill).toEqual(DRILL)
666
+ })
667
+ })
668
+
562
669
  describe('useReportDesigner:template 版本校验', () => {
563
670
  it('载入缺少 version 的模板时抛可读错误', () => {
564
671
  const { connector } = createStubConnector()
@@ -1,4 +1,4 @@
1
- import type { CellAddress } from '@veltra/sheet-core'
1
+ import type { CellAddress, CellAlign } from '@veltra/sheet-core'
2
2
  import {
3
3
  CustomLayout,
4
4
  type ResolveCellRenderer,
@@ -51,14 +51,38 @@ export function createBindingBadgeStyleResolver(
51
51
  }
52
52
  }
53
53
 
54
+ /** 徽章容器 flex 布局属性(`flexDirection: 'row'` 下主轴 = 水平) */
55
+ interface BadgeLayoutProps {
56
+ justifyContent: 'flex-start' | 'center' | 'flex-end'
57
+ alignItems: 'flex-start' | 'center' | 'flex-end'
58
+ }
59
+
60
+ /**
61
+ * 单元格对齐 → 徽章容器 flex 布局映射(纯函数,便于单测):
62
+ * `horizontal` left / 缺省 → 主轴起点;`vertical` middle / 缺省 → 交叉轴居中。
63
+ */
64
+ export function badgeLayoutProps(align: CellAlign | undefined): BadgeLayoutProps {
65
+ const justifyContent =
66
+ align?.horizontal === 'center'
67
+ ? 'center'
68
+ : align?.horizontal === 'right'
69
+ ? 'flex-end'
70
+ : 'flex-start'
71
+ const alignItems =
72
+ align?.vertical === 'top' ? 'flex-start' : align?.vertical === 'bottom' ? 'flex-end' : 'center'
73
+ return { justifyContent, alignItems }
74
+ }
75
+
54
76
  /**
55
77
  * 绑定格富渲染徽章(ADR-0004 `resolveCellRenderer` 首个消费者):
56
- * 绑定单元格渲染为左对齐徽章(强调色聚合标签 + 中性色字段标签),
78
+ * 绑定单元格渲染为徽章布局(强调色聚合标签 + 中性色字段标签),
79
+ * 布局跟随单元格水平 / 垂直对齐(经 `resolveAlign` 读有效样式);
57
80
  * 未绑定格返回 `undefined` 回落默认渲染。
58
81
  */
59
82
  export function createBindingBadgeRenderer(
60
83
  getBindingAt: (addr: CellAddress) => ReportBinding | undefined,
61
- resolveLabel?: (datasetId: string, fieldName: string) => string
84
+ resolveLabel?: (datasetId: string, fieldName: string) => string,
85
+ resolveAlign?: (addr: CellAddress) => CellAlign | undefined
62
86
  ): ResolveCellRenderer {
63
87
  return (addr) => {
64
88
  const binding = getBindingAt(addr)
@@ -66,12 +90,13 @@ export function createBindingBadgeRenderer(
66
90
 
67
91
  const { fg } = resolveBadgeColor(binding)
68
92
  const { tag, label } = formatBindingPlaceholderParts(binding, resolveLabel)
93
+ const { justifyContent, alignItems } = badgeLayoutProps(resolveAlign?.(addr))
69
94
 
70
95
  const rootContainer = new CustomLayout.Container({
71
96
  display: 'flex',
72
97
  flexDirection: 'row',
73
- justifyContent: 'flex-start',
74
- alignItems: 'center'
98
+ justifyContent,
99
+ alignItems
75
100
  })
76
101
  // VTable 布局层 Tag 组件(vrender-components)与其 INode 声明存在结构性类型偏差,
77
102
  // 运行时完全兼容(VTable 官方 customLayout 徽章用法即如此),此处仅收敛类型
@@ -87,6 +112,11 @@ export function createBindingBadgeRenderer(
87
112
  rootContainer.add(
88
113
  new CustomLayout.Text({ text: label, fontSize: 11, fontWeight: 500, fill: BADGE_LABEL_COLOR })
89
114
  )
115
+ if (binding.drill) {
116
+ rootContainer.add(
117
+ new CustomLayout.Text({ text: ' ↗', fontSize: 11, fontWeight: 700, fill: fg })
118
+ )
119
+ }
90
120
  return { rootContainer, renderDefault: false }
91
121
  }
92
122
  }
@@ -0,0 +1,245 @@
1
+ <template>
2
+ <u-dialog
3
+ v-model="visible"
4
+ title="下钻配置"
5
+ style="width: min(560px, 92vw); max-height: 85vh"
6
+ @close="onClose"
7
+ >
8
+ <div :class="cls.b">
9
+ <section :class="cls.e('section')">
10
+ <div :class="cls.e('label')">目标模板</div>
11
+ <u-select
12
+ size="small"
13
+ placeholder="从模板列表选择目标"
14
+ :model-value="target"
15
+ :options="templateOptions"
16
+ @update:model-value="onTarget"
17
+ />
18
+ <p :class="cls.e('hint')">目标模板仅从宿主提供的列表选择,不支持手填引用。</p>
19
+ </section>
20
+
21
+ <section :class="cls.e('section')">
22
+ <div :class="cls.e('label')">打开方式</div>
23
+ <u-radio-group
24
+ size="small"
25
+ :items="openModeItems"
26
+ :model-value="openMode"
27
+ @update:model-value="onOpenMode"
28
+ />
29
+ </section>
30
+
31
+ <section :class="cls.e('section')">
32
+ <div :class="cls.e('label')">字段 → 详情报参数映射</div>
33
+ <p v-if="!target" :class="cls.e('hint')">先选择目标模板,再配置映射。</p>
34
+ <p v-else-if="paramsLoading" :class="cls.e('hint')">正在解析目标模板参数…</p>
35
+ <p v-else-if="paramsError" :class="cls.e('error')">{{ paramsError }}</p>
36
+ <template v-else>
37
+ <p v-if="targetParams.length === 0" :class="cls.e('hint')">
38
+ 目标模板没有查询参数,无需配置映射。
39
+ </p>
40
+ <ul v-else :class="cls.e('list')">
41
+ <li v-for="(row, index) in mappings" :key="index" :class="cls.e('mapping')">
42
+ <u-select
43
+ size="small"
44
+ :class="cls.e('field')"
45
+ placeholder="字段"
46
+ :model-value="row.field"
47
+ :options="fieldOptions"
48
+ @update:model-value="row.field = $event"
49
+ />
50
+ <span :class="cls.e('arrow')">→</span>
51
+ <u-select
52
+ size="small"
53
+ :class="cls.e('param')"
54
+ placeholder="参数"
55
+ :model-value="row.param"
56
+ :options="paramOptions"
57
+ @update:model-value="row.param = $event"
58
+ />
59
+ <u-button size="small" text type="danger" @click="removeMapping(index)">
60
+ 删除
61
+ </u-button>
62
+ </li>
63
+ </ul>
64
+ <div v-if="targetParams.length > 0">
65
+ <u-button size="small" plain @click="addMapping">添加映射</u-button>
66
+ </div>
67
+ </template>
68
+ </section>
69
+ </div>
70
+
71
+ <template #footer>
72
+ <u-button
73
+ v-if="drill"
74
+ size="small"
75
+ text
76
+ type="danger"
77
+ style="margin-right: auto"
78
+ @click="remove"
79
+ >
80
+ 移除下钻
81
+ </u-button>
82
+ <u-button size="small" @click="visible = false">取消</u-button>
83
+ <u-button size="small" type="primary" :disabled="!target" @click="confirm">确定</u-button>
84
+ </template>
85
+ </u-dialog>
86
+ </template>
87
+
88
+ <script lang="ts" setup>
89
+ import { UButton, UDialog, URadioGroup, USelect } from '@veltra/desktop'
90
+ import { bem } from '@veltra/utils'
91
+ import { computed, ref, watch } from 'vue'
92
+
93
+ import type { ResolveReportTemplate } from '../../../report/drill'
94
+ import { resolveTemplateParams } from '../../../report/template'
95
+ import type {
96
+ DatasetField,
97
+ QueryParamDef,
98
+ ReportDrillConfig,
99
+ ReportTemplateListItem
100
+ } from '../../../report/types'
101
+
102
+ defineOptions({ name: 'UReportDrillDialog' })
103
+
104
+ /** 映射编辑行草稿(保存时折叠为 Record<字段, 参数>) */
105
+ interface MappingRow {
106
+ field: string
107
+ param: string
108
+ }
109
+
110
+ const props = withDefaults(
111
+ defineProps<{
112
+ modelValue: boolean
113
+ /** 宿主提供的可下钻目标模板列表(目标必选,无手填输入) */
114
+ templates: ReportTemplateListItem[]
115
+ /** 当前绑定所在数据集的字段 catalog */
116
+ fields?: DatasetField[]
117
+ /** 已有下钻配置(编辑时回填;缺省为新建) */
118
+ drill?: ReportDrillConfig
119
+ /** 宿主模板解析契约:选中目标后解析其查询参数;缺失时映射区给出可读提示 */
120
+ resolveTemplate?: ResolveReportTemplate
121
+ }>(),
122
+ { fields: () => [], drill: undefined, resolveTemplate: undefined }
123
+ )
124
+
125
+ const emit = defineEmits<{
126
+ 'update:modelValue': [value: boolean]
127
+ save: [drill: ReportDrillConfig]
128
+ remove: []
129
+ }>()
130
+
131
+ const cls = bem('report-drill-dialog')
132
+
133
+ const visible = computed({
134
+ get: () => props.modelValue,
135
+ set: (value: boolean) => emit('update:modelValue', value)
136
+ })
137
+
138
+ const openModeItems = [
139
+ { value: 'switch' as const, label: '查看器内切换' },
140
+ { value: 'dialog' as const, label: '弹框打开(UDialog)' }
141
+ ]
142
+
143
+ // ---- 草稿(打开时从 props.drill 回填) ----
144
+
145
+ const target = ref('')
146
+ const openMode = ref<ReportDrillConfig['openMode']>('switch')
147
+ const mappings = ref<MappingRow[]>([])
148
+
149
+ watch(
150
+ () => props.modelValue,
151
+ (open) => {
152
+ if (!open) return
153
+ target.value = props.drill?.target ?? ''
154
+ openMode.value = props.drill?.openMode ?? 'switch'
155
+ mappings.value = Object.entries(props.drill?.mapping ?? {}).map(([field, param]) => ({
156
+ field,
157
+ param
158
+ }))
159
+ void resolveTargetParams(target.value)
160
+ }
161
+ )
162
+
163
+ // ---- 目标模板参数解析(并发守卫:只应用最后一次解析) ----
164
+
165
+ const targetParams = ref<QueryParamDef[]>([])
166
+ const paramsLoading = ref(false)
167
+ const paramsError = ref('')
168
+ let paramsSeq = 0
169
+
170
+ async function resolveTargetParams(ref: string): Promise<void> {
171
+ const seq = ++paramsSeq
172
+ targetParams.value = []
173
+ paramsError.value = ''
174
+ if (!ref) return
175
+ const resolve = props.resolveTemplate
176
+ if (!resolve) {
177
+ paramsError.value = '宿主未提供 resolveTemplate,无法解析目标模板参数'
178
+ return
179
+ }
180
+ paramsLoading.value = true
181
+ try {
182
+ const template = await resolve(ref)
183
+ if (seq !== paramsSeq) return
184
+ targetParams.value = resolveTemplateParams(template)
185
+ } catch (error) {
186
+ if (seq !== paramsSeq) return
187
+ paramsError.value = `解析目标模板参数失败:${error instanceof Error ? error.message : String(error)}`
188
+ } finally {
189
+ if (seq === paramsSeq) paramsLoading.value = false
190
+ }
191
+ }
192
+
193
+ const templateOptions = computed(() =>
194
+ props.templates.map((item) => ({ value: item.ref, label: item.label }))
195
+ )
196
+
197
+ const fieldOptions = computed(() =>
198
+ props.fields.map((field) => ({ value: field.name, label: field.label }))
199
+ )
200
+
201
+ const paramOptions = computed(() =>
202
+ targetParams.value.map((param) => ({
203
+ value: param.id,
204
+ label: param.label && param.label !== param.id ? `${param.label}(${param.id})` : param.id
205
+ }))
206
+ )
207
+
208
+ /** 用户改选目标:旧映射的参数 id 未必存在于新模板,直接清空重配 */
209
+ function onTarget(value: string): void {
210
+ target.value = value ?? ''
211
+ mappings.value = []
212
+ void resolveTargetParams(target.value)
213
+ }
214
+
215
+ function onOpenMode(value: ReportDrillConfig['openMode']): void {
216
+ openMode.value = value
217
+ }
218
+
219
+ function addMapping(): void {
220
+ mappings.value = [...mappings.value, { field: '', param: '' }]
221
+ }
222
+
223
+ function removeMapping(index: number): void {
224
+ mappings.value = mappings.value.filter((_, i) => i !== index)
225
+ }
226
+
227
+ function confirm(): void {
228
+ if (!target.value) return
229
+ const mapping: Record<string, string> = {}
230
+ for (const row of mappings.value) {
231
+ if (row.field && row.param) mapping[row.field] = row.param
232
+ }
233
+ emit('save', { target: target.value, mapping, openMode: openMode.value })
234
+ visible.value = false
235
+ }
236
+
237
+ function remove(): void {
238
+ emit('remove')
239
+ visible.value = false
240
+ }
241
+
242
+ function onClose(): void {
243
+ visible.value = false
244
+ }
245
+ </script>
@@ -37,6 +37,15 @@
37
37
  @update:model-value="onExpand"
38
38
  />
39
39
  <u-button size="small" plain @click="emit('open-rules')">条件样式</u-button>
40
+ <u-button
41
+ v-if="showDrill"
42
+ size="small"
43
+ plain
44
+ :type="binding.drill ? 'primary' : undefined"
45
+ @click="emit('open-drill')"
46
+ >
47
+ 下钻
48
+ </u-button>
40
49
  <u-button size="small" text type="danger" @click="emit('remove')">删除绑定</u-button>
41
50
  <u-button
42
51
  size="small"
@@ -190,6 +199,8 @@ const props = defineProps<{
190
199
  parentPickMode: ParentPickMode | null
191
200
  rowParentCandidates: CellAddress[]
192
201
  colParentCandidates: CellAddress[]
202
+ /** 宿主传入了可下钻模板列表(下钻配置入口可见性由父级按 props 计算) */
203
+ showDrill?: boolean
193
204
  resolveFieldLabel?: (datasetId: string, fieldName: string) => string
194
205
  getBindingAt?: (addr: CellAddress) => ReportBinding | undefined
195
206
  hostEl: HTMLElement | null
@@ -200,6 +211,7 @@ const emit = defineEmits<{
200
211
  patch: [patch: Partial<ReportBinding>]
201
212
  remove: []
202
213
  'open-rules': []
214
+ 'open-drill': []
203
215
  'start-parent-pick': [mode: ParentPickMode]
204
216
  'cancel-parent-pick': []
205
217
  'clear-parent': [mode: ParentPickMode]
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 打印 HTML 文档:隐藏 iframe 装载后调起浏览器打印,结束(或兜底超时)销毁。
3
+ *
4
+ * 路线取舍:相对 `window.open` 不被弹窗拦截;相对 `@media print` 直打宿主页,
5
+ * 不侵入应用布局,也不受网格 canvas 虚拟滚动(只绘制视口)限制。
6
+ * iframe 用 0×0 定位隐藏而非 `display:none`(部分浏览器不打印 none 的 iframe)。
7
+ */
8
+ export function printHtmlDocument(html: string): void {
9
+ const iframe = document.createElement('iframe')
10
+ iframe.setAttribute('aria-hidden', 'true')
11
+ iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;'
12
+ document.body.appendChild(iframe)
13
+
14
+ const win = iframe.contentWindow
15
+ const doc = iframe.contentDocument
16
+ if (!win || !doc || typeof win.print !== 'function') {
17
+ iframe.remove()
18
+ throw new Error('当前环境不支持打印')
19
+ }
20
+
21
+ let cleaned = false
22
+ const cleanup = (): void => {
23
+ if (cleaned) return
24
+ cleaned = true
25
+ iframe.remove()
26
+ }
27
+ win.addEventListener('afterprint', cleanup)
28
+
29
+ doc.open()
30
+ doc.write(html)
31
+ doc.close()
32
+
33
+ // 等内嵌图片(data URL)解码完再打印,避免打印快照取到未解码图片
34
+ const decoding = Array.from(doc.images).map((img) =>
35
+ typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve()
36
+ )
37
+ void Promise.all(decoding).then(() => {
38
+ if (cleaned) return
39
+ win.focus()
40
+ win.print()
41
+ // 兜底回收:afterprint 在部分环境不触发,或用户长期挂着打印对话框
42
+ setTimeout(cleanup, 60_000)
43
+ })
44
+ }
@@ -106,6 +106,7 @@
106
106
  :parent-pick-mode="parentPick?.mode ?? null"
107
107
  :row-parent-candidates="rowParentCandidates"
108
108
  :col-parent-candidates="colParentCandidates"
109
+ :show-drill="hasDrillTemplates"
109
110
  :resolve-field-label="resolveFieldLabel"
110
111
  :get-binding-at="getBindingAt"
111
112
  :host-el="gridHostEl"
@@ -113,6 +114,7 @@
113
114
  @patch="patchActiveBinding"
114
115
  @remove="removeActiveBinding"
115
116
  @open-rules="rulesDialogVisible = true"
117
+ @open-drill="drillDialogVisible = true"
116
118
  @start-parent-pick="startParentPick"
117
119
  @cancel-parent-pick="cancelParentPick"
118
120
  @clear-parent="clearParent"
@@ -126,6 +128,7 @@
126
128
  :connector="props.connector"
127
129
  :template="previewTemplate"
128
130
  :workbook="previewWorkbook"
131
+ :resolve-template="props.resolveTemplate"
129
132
  />
130
133
  </div>
131
134
 
@@ -141,6 +144,17 @@
141
144
  :field-type="activeFieldType"
142
145
  @save="saveConditionalRules"
143
146
  />
147
+
148
+ <u-report-drill-dialog
149
+ v-if="hasDrillTemplates"
150
+ v-model="drillDialogVisible"
151
+ :templates="props.drillTemplates ?? []"
152
+ :fields="activeDatasetFields"
153
+ :drill="activeBinding?.drill"
154
+ :resolve-template="props.resolveTemplate"
155
+ @save="saveDrill"
156
+ @remove="removeDrill"
157
+ />
144
158
  </div>
145
159
  </template>
146
160
 
@@ -153,7 +167,7 @@ import { bem } from '@veltra/utils'
153
167
  import { computed, nextTick, onScopeDispose, ref, shallowRef, useTemplateRef, watch } from 'vue'
154
168
 
155
169
  import type { ReportTemplate } from '../../report/template'
156
- import type { ConditionalRule } from '../../report/types'
170
+ import type { ConditionalRule, ReportDrillConfig } from '../../report/types'
157
171
  import type {
158
172
  ReportDesignerEmits,
159
173
  ReportDesignerProps,
@@ -165,6 +179,7 @@ import { USheet } from '../sheet'
165
179
  import UReportDatasetHub from './designer-hub.vue'
166
180
  import { resolveGridDropAddress } from './designer/cell-coords'
167
181
  import { applyGridColWidths, applySheetColWidths, readGridColWidths } from './designer/col-widths'
182
+ import UReportDrillDialog from './designer/drill-dialog.vue'
168
183
  import UReportDropHighlightOverlay from './designer/drop-highlight-overlay.vue'
169
184
  import UReportFloatPanel from './designer/float-panel.vue'
170
185
  import UReportRulesDialog from './designer/rules-dialog.vue'
@@ -176,7 +191,13 @@ import { useReportDesigner } from './use-report-designer'
176
191
 
177
192
  defineOptions({ name: 'UReportDesigner' })
178
193
 
179
- const props = withDefaults(defineProps<ReportDesignerProps>(), { connections: () => [] })
194
+ /** 与 `ReportDesignerProps` 交叉显式列出下钻 props,避免 SFC 对跨文件 interface 漏抽 runtime 声明 */
195
+ type DesignerRuntimeProps = ReportDesignerProps & {
196
+ drillTemplates?: ReportDesignerProps['drillTemplates']
197
+ resolveTemplate?: ReportDesignerProps['resolveTemplate']
198
+ }
199
+
200
+ const props = withDefaults(defineProps<DesignerRuntimeProps>(), { connections: () => [] })
180
201
  const emit = defineEmits<ReportDesignerEmits>()
181
202
 
182
203
  const cls = bem('report-designer')
@@ -237,6 +258,10 @@ const gridCols = computed(() => {
237
258
 
238
259
  const hubVisible = ref(false)
239
260
  const rulesDialogVisible = ref(false)
261
+ const drillDialogVisible = ref(false)
262
+
263
+ /** 宿主传入可下钻模板列表时才出现下钻配置入口(不传则无入口,spec 兼容性要求) */
264
+ const hasDrillTemplates = computed(() => (props.drillTemplates?.length ?? 0) > 0)
240
265
 
241
266
  const pickCandidateCells = computed((): CellAddress[] => {
242
267
  if (!parentPick.value) return []
@@ -364,6 +389,14 @@ function saveConditionalRules(rules: ConditionalRule[]): void {
364
389
  patchActiveBinding({ conditionalRules: rules })
365
390
  }
366
391
 
392
+ function saveDrill(drill: ReportDrillConfig): void {
393
+ patchActiveBinding({ drill })
394
+ }
395
+
396
+ function removeDrill(): void {
397
+ patchActiveBinding({ drill: undefined })
398
+ }
399
+
367
400
  function clearDropState(): void {
368
401
  isFieldDragging.value = false
369
402
  dropTargetAddr.value = null