@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.
- package/dist/components/report/binding-badge.js +23 -5
- package/dist/components/report/designer/drill-dialog.js +258 -0
- package/dist/components/report/designer/float-panel.js +26 -14
- package/dist/components/report/print.js +39 -0
- package/dist/components/report/report-designer.js +56 -21
- package/dist/components/report/report-viewer.js +173 -44
- package/dist/components/report/style2.css +91 -0
- package/dist/components/report/use-report-designer.js +2 -1
- package/dist/components/report/use-report-viewer.js +88 -3
- package/dist/components/sheet/insert-image.js +1 -1
- package/dist/components/sheet/popups/import.worker.js +2 -3
- package/dist/components/sheet/use-sheet-state.js +2 -2
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -2
- package/dist/report/drill.d.ts +48 -0
- package/dist/report/drill.js +78 -0
- package/dist/report/export-xlsx.d.ts +3 -7
- package/dist/report/export-xlsx.js +6 -85
- package/dist/report/index.d.ts +5 -3
- package/dist/report/print.d.ts +23 -0
- package/dist/report/print.js +197 -0
- package/dist/report/render/aggregate.js +25 -1
- package/dist/report/types.d.ts +19 -1
- package/dist/tools/download.js +2 -2
- package/dist/types/report-designer.d.ts +13 -0
- package/dist/types/report-viewer.d.ts +19 -0
- package/package.json +6 -9
- package/src/components/report/__test__/binding-badge.test.ts +92 -0
- package/src/components/report/__test__/report-designer.test.ts +98 -3
- package/src/components/report/__test__/report-drill.test.ts +692 -0
- package/src/components/report/__test__/report-viewer.test.ts +43 -0
- package/src/components/report/__test__/use-report-designer.test.ts +108 -1
- package/src/components/report/binding-badge.ts +35 -5
- package/src/components/report/designer/drill-dialog.vue +245 -0
- package/src/components/report/designer/float-panel.vue +12 -0
- package/src/components/report/print.ts +44 -0
- package/src/components/report/report-designer.vue +35 -2
- package/src/components/report/report-viewer.vue +171 -18
- package/src/components/report/style-designer.scss +66 -0
- package/src/components/report/style-viewer.scss +40 -0
- package/src/components/report/use-report-designer.ts +8 -1
- package/src/components/report/use-report-viewer.ts +150 -11
- package/src/components/sheet/insert-image.ts +1 -1
- package/src/components/sheet/popups/export-popup.vue +1 -1
- package/src/components/sheet/popups/import.worker.ts +7 -10
- package/src/components/sheet/use-sheet-state.ts +2 -2
- package/src/report/__test__/drill.test.ts +194 -0
- package/src/report/__test__/export-xlsx.test.ts +3 -25
- package/src/report/__test__/print.test.ts +170 -0
- package/src/report/__test__/render.test.ts +26 -0
- package/src/report/drill.ts +112 -0
- package/src/report/export-xlsx.ts +5 -103
- package/src/report/index.ts +4 -1
- package/src/report/print.ts +234 -0
- package/src/report/render/aggregate.ts +28 -0
- package/src/report/types.ts +20 -0
- package/src/tools/__test__/builtin.test.ts +1 -1
- package/src/tools/download.ts +2 -2
- package/src/tools/export.worker.ts +2 -2
- package/src/types/report-designer.ts +18 -1
- 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 {
|
|
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
|
|
74
|
-
alignItems
|
|
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
|
-
|
|
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
|